diff --git a/.changeset/fast-needles-sort.md b/.changeset/fast-needles-sort.md new file mode 100644 index 00000000000..a65c45378c0 --- /dev/null +++ b/.changeset/fast-needles-sort.md @@ -0,0 +1,11 @@ +--- +"@remix-run/dev": patch +--- + +Change Vite build output paths to fix a conflict between how Vite and the Remix compiler each manage the `public` directory. + +**This is a breaking change for projects using the unstable Vite plugin.** + +The server is now compiled into `build/server` rather than `build`, and the client is now compiled into `build/client` rather than `public`. + +For more information on the changes and guidance on how to migrate your project, refer to the updated [Remix Vite documentation](https://remix.run/docs/en/main/future/vite). \ No newline at end of file diff --git a/.changeset/proud-otters-cheat.md b/.changeset/proud-otters-cheat.md deleted file mode 100644 index b5fb8fc1108..00000000000 --- a/.changeset/proud-otters-cheat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@remix-run/dev": patch ---- - -Fix redundant copying of assets from `public` directory in Vite build. This ensures that static assets aren't duplicated in the server build directory. This also fixes an issue where the build would break if `assetsBuildDirectory` was deeply nested within the `public` directory. diff --git a/docs/future/vite.md b/docs/future/vite.md index 94fe2de3cb6..ca80b822fe0 100644 --- a/docs/future/vite.md +++ b/docs/future/vite.md @@ -66,6 +66,21 @@ export default defineConfig({ All other bundling-related options are now [configured with Vite][vite-config]. This means you have much greater control over the bundling process. +## New build output paths + +There is a notable difference with the way Vite manages the `public` directory compared to the existing Remix compiler. During the build, Vite copies files from the `public` directory into `build/client`, whereas the Remix compiler left the `public` directory untouched and used a subdirectory (`public/build`) as the client build directory. + +In order to align the default Remix project structure with the way Vite works, the build output paths have been changed. + +- The server is now compiled into `build/server` by default. +- The client is now compiled into `build/client` by default. + +This means that the following configuration defaults have been changed: + +- [assetsBuildDirectory][assets-build-directory] defaults to `"build/client"` rather than `"public/build"` +- [publicPath][public-path] defaults to `"/"` rather than `"/build/"` +- [serverBuildPath][server-build-path] defaults to `"build/server/index.js"` rather than `"build/index.js"` + ## Additional features & plugins One of the reasons that Remix is moving to Vite is, so you have less to learn when adopting Remix. @@ -132,21 +147,25 @@ Vite handles imports for all sorts of different file types, sometimes in ways th If you were using `remix-serve` in development (or `remix dev` without the `-c` flag), you'll need to switch to the new minimal dev server. It comes built-in with the Remix Vite plugin and will take over when you run `vite dev`. -👉 **Update your `dev` and `build` scripts** +You'll also need to update to the new build output paths, which are `build/server` for the server and `build/client` for client assets. + +👉 **Update your `dev`, `build` and `start` scripts** ```json filename=package.json lines=[3-4] { "scripts": { "build": "vite build && vite build --ssr", "dev": "vite dev", - "start": "remix-serve ./build/index.js" + "start": "remix-serve ./build/server/index.js" } } ``` #### Migrating from a custom server -If you were using a custom server in development, you'll need to edit your custom server to use Vite's `connect` middleware. +If you were using a custom server in development, you'll need to update your server code to reference the new build output paths, which are `build/server` for the server build and `build/client` for client assets. + +You'll also need to edit your custom server to use Vite's `connect` middleware. This will delegate asset requests and initial render requests to Vite during development, letting you benefit from Vite's excellent DX even with a custom server. Remix exposes APIs for exactly this purpose: @@ -185,14 +204,14 @@ if (vite) { app.use(vite.middlewares); } else { app.use( - "/build", - express.static("public/build", { + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y", }) ); } -app.use(express.static("public", { maxAge: "1h" })); +app.use(express.static("build/client", { maxAge: "1h" })); // handle SSR requests app.all( @@ -200,7 +219,7 @@ app.all( createRequestHandler({ build: vite ? () => unstable_loadViteServerBuild(vite) - : await import("./build/index.js"), + : await import("./build/server/index.js"), }) ); @@ -232,6 +251,24 @@ node --loader tsm ./server.ts Just remember that there might be some noticeable slowdown for initial server startup if you do this. +#### Migrate references to build output paths + +When using the existing Remix compiler's default options, the server was compiled into `build` and the client was compiled into `public/build`. Due to differences with the way Vite typically works with its `public` directory compared to the existing Remix compiler, these output paths have changed. + +👉 **Update references to build output paths** + +- The server is now compiled into `build/server` by default. +- The client is now compiled into `build/client` by default. + +For example, to update the Dockerfile from the [Blues Stack][blues-stack]: + +```diff filename=Dockerfile +-COPY --from=build /myapp/build /myapp/build +-COPY --from=build /myapp/public /myapp/public ++COPY --from=build /myapp/server/build /myapp/server/build ++COPY --from=build /myapp/client/build /myapp/client/build +``` + #### Configure path aliases The Remix compiler leverages the `paths` option in your `tsconfig.json` to resolve path aliases. This is commonly used in the Remix community to define `~` as an alias for the `app` directory. @@ -655,3 +692,4 @@ We're definitely late to the Vite party, but we're excited to be here now! [vite-plugin-cjs-interop]: https://github.com/cyco130/vite-plugin-cjs-interop [ssr-no-external]: https://vitejs.dev/config/ssr-options.html#ssr-noexternal [server-dependencies-to-bundle]: https://remix.run/docs/en/main/file-conventions/remix-config#serverdependenciestobundle +[blues-stack]: https://github.com/remix-run/blues-stack diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 3ba594cf237..98c130b4532 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -46,9 +46,13 @@ export function json(value: JsonObject) { export async function createFixture(init: FixtureInit, mode?: ServerMode) { installGlobals(); + let compiler = init.compiler ?? "remix"; let projectDir = await createFixtureProject(init, mode); let buildPath = url.pathToFileURL( - path.join(projectDir, "build/index.js") + path.join( + projectDir, + compiler === "vite" ? "build/server/index.js" : "build/index.js" + ) ).href; let app: ServerBuild = await import(buildPath); let handler = createRequestHandler(app, mode || ServerMode.Production); @@ -98,6 +102,7 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { return { projectDir, build: app, + compiler, requestDocument, requestData, postDocument, @@ -118,7 +123,12 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { let nodebin = process.argv[0]; let serveProcess = spawn( nodebin, - ["node_modules/@remix-run/serve/dist/cli.js", "build/index.js"], + [ + "node_modules/@remix-run/serve/dist/cli.js", + fixture.compiler === "vite" + ? "server/build/index.js" + : "build/index.js", + ], { env: { NODE_ENV: mode || "production", @@ -171,7 +181,14 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { return new Promise(async (accept) => { let port = await getPort(); let app = express(); - app.use(express.static(path.join(fixture.projectDir, "public"))); + app.use( + express.static( + path.join( + fixture.projectDir, + fixture.compiler === "vite" ? "build/client" : "public" + ) + ) + ); app.all( "*", diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts index 09a752494dc..5025ca010a3 100644 --- a/integration/vite-build-test.ts +++ b/integration/vite-build-test.ts @@ -242,12 +242,12 @@ test.describe("Vite build", () => { appFixture.close(); }); - test("server code is removed from client assets", async () => { - let publicBuildDir = path.join(fixture.projectDir, "public/build"); + test("server code is removed from client build", async () => { + let clientBuildDir = path.join(fixture.projectDir, "build/client"); // detect client asset files let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { - cwd: publicBuildDir, + cwd: clientBuildDir, absolute: true, }); @@ -307,12 +307,12 @@ test.describe("Vite build", () => { // verify asset files are emitted and served correctly await page.getByRole("link", { name: "url1" }).click(); - await page.waitForURL("**/build/assets/test1-*.txt"); + await page.waitForURL("**/assets/test1-*.txt"); await page.getByText("test1").click(); await page.goBack(); await page.getByRole("link", { name: "url2" }).click(); - await page.waitForURL("**/build/assets/test2-*.txt"); + await page.waitForURL("**/assets/test2-*.txt"); await page.getByText("test2").click(); }); @@ -333,7 +333,7 @@ test.describe("Vite build", () => { test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { let assetFiles = glob.sync("*", { - cwd: path.join(fixture.projectDir, "build/assets"), + cwd: path.join(fixture.projectDir, "build/server/assets"), }); let [asset, ...rest] = assetFiles; expect(rest).toEqual([]); // Provide more useful test output if this fails diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 61eb4a65f22..9e0ee66c77c 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -45,14 +45,34 @@ const supportedRemixConfigKeys = [ "serverModuleFormat", ] as const satisfies ReadonlyArray; type SupportedRemixConfigKey = typeof supportedRemixConfigKeys[number]; - -export type RemixVitePluginOptions = Pick< - RemixUserConfig, - SupportedRemixConfigKey -> & { - legacyCssImports?: boolean; +type SupportedRemixConfig = Pick; + +// We need to provide different JSDoc comments in some cases due to differences +// between the Remix config and the Vite plugin. +type RemixConfigJsdocOverrides = { + /** + * The path to the browser build, relative to the project root. Defaults to + * `"build/client"`. + */ + assetsBuildDirectory?: SupportedRemixConfig["assetsBuildDirectory"]; + /** + * The URL prefix of the browser build with a trailing slash. Defaults to + * `"/"`. This is the path the browser will use to find assets. + */ + publicPath?: SupportedRemixConfig["publicPath"]; + /** + * The path to the server build file, relative to the project. This file + * should end in a `.js` extension and should be deployed to your server. + * Defaults to `"build/server/index.js"`. + */ + serverBuildPath?: SupportedRemixConfig["serverBuildPath"]; }; +export type RemixVitePluginOptions = RemixConfigJsdocOverrides & + Omit & { + legacyCssImports?: boolean; + }; + type ResolvedRemixVitePluginConfig = Pick< ResolvedRemixConfig, | "appDirectory" @@ -255,12 +275,20 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { let resolvePluginConfig = async (): Promise => { + let defaults: Partial = { + serverBuildPath: "build/server/index.js", + assetsBuildDirectory: "build/client", + publicPath: "/", + }; + + let config = { + ...defaults, + ...pick(options, supportedRemixConfigKeys), // Avoid leaking any config options that the Vite plugin doesn't support + }; + let rootDirectory = viteUserConfig.root ?? process.env.REMIX_ROOT ?? process.cwd(); - // Avoid leaking any config options that the Vite plugin doesn't support - let config = pick(options, supportedRemixConfigKeys); - // Only select the Remix config options that the Vite plugin uses let { appDirectory, @@ -385,8 +413,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { let fingerprintedValues = { entry, routes }; let version = getHash(JSON.stringify(fingerprintedValues), 8); - let manifestFilename = `manifest-${version}.js`; - let url = `${pluginConfig.publicPath}${manifestFilename}`; + let manifestPath = `assets/manifest-${version}.js`; + let url = `${pluginConfig.publicPath}${manifestPath}`; let nonFingerprintedValues = { url, version }; let manifest: Manifest = { @@ -395,7 +423,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }; await writeFileSafe( - path.join(pluginConfig.assetsBuildDirectory, manifestFilename), + path.join(pluginConfig.assetsBuildDirectory, manifestPath), `window.__remixManifest=${JSON.stringify(manifest)};` ); @@ -516,13 +544,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { base: pluginConfig.publicPath, build: { ...viteUserConfig.build, - // By convention Remix builds into a subdirectory within the - // public directory ("public/build" by default) so we don't want - // to copy the contents of the public directory around. This also - // ensures that we don't get caught in an infinite loop when - // `assetsBuildDirectory` is nested multiple levels deep within - // the public directory, e.g. "public/custom-base-dir/build" - copyPublicDir: false, ...(!isSsrBuild ? { manifest: true, @@ -545,6 +566,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { // regardless of "ssrEmitAssets" option, so we also need to // keep these JS files have to be kept as-is. ssrEmitAssets: true, + copyPublicDir: false, // Assets in the public directory are only used by the client manifest: true, // We need the manifest to detect SSR-only assets outDir: path.dirname(pluginConfig.serverBuildPath), rollupOptions: { diff --git a/templates/unstable-vite-express/.gitignore b/templates/unstable-vite-express/.gitignore index 3f7bf98da3e..80ec311f4ff 100644 --- a/templates/unstable-vite-express/.gitignore +++ b/templates/unstable-vite-express/.gitignore @@ -2,5 +2,4 @@ node_modules /.cache /build -/public/build .env diff --git a/templates/unstable-vite-express/server.mjs b/templates/unstable-vite-express/server.mjs index bef73042b3b..41b0611d1a5 100644 --- a/templates/unstable-vite-express/server.mjs +++ b/templates/unstable-vite-express/server.mjs @@ -8,7 +8,7 @@ import express from "express"; installGlobals(); -let vite = +const vite = process.env.NODE_ENV === "production" ? undefined : await unstable_createViteServer(); @@ -20,11 +20,11 @@ if (vite) { app.use(vite.middlewares); } else { app.use( - "/build", - express.static("public/build", { immutable: true, maxAge: "1y" }) + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }) ); } -app.use(express.static("public", { maxAge: "1h" })); +app.use(express.static("build/client", { maxAge: "1h" })); // handle SSR requests app.all( @@ -32,7 +32,7 @@ app.all( createRequestHandler({ build: vite ? () => unstable_loadViteServerBuild(vite) - : await import("./build/index.js"), + : await import("./build/server/index.js"), }) ); diff --git a/templates/unstable-vite/.gitignore b/templates/unstable-vite/.gitignore index 3f7bf98da3e..80ec311f4ff 100644 --- a/templates/unstable-vite/.gitignore +++ b/templates/unstable-vite/.gitignore @@ -2,5 +2,4 @@ node_modules /.cache /build -/public/build .env diff --git a/templates/unstable-vite/package.json b/templates/unstable-vite/package.json index a25a9ffec52..254a96df4dc 100644 --- a/templates/unstable-vite/package.json +++ b/templates/unstable-vite/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "vite dev", "build": "vite build && vite build --ssr", - "start": "remix-serve ./build/index.js", + "start": "remix-serve ./build/server/index.js", "typecheck": "tsc" }, "dependencies": {