diff --git a/.changeset/five-peaches-attend.md b/.changeset/five-peaches-attend.md new file mode 100644 index 00000000000..e74339d6647 --- /dev/null +++ b/.changeset/five-peaches-attend.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Remove Vite plugin config option `serverBuildPath` in favor of separate `serverBuildDirectory` and `serverBuildFile` options diff --git a/.changeset/flat-toys-hope.md b/.changeset/flat-toys-hope.md new file mode 100644 index 00000000000..deb48581f62 --- /dev/null +++ b/.changeset/flat-toys-hope.md @@ -0,0 +1,30 @@ +--- +"@remix-run/dev": minor +--- + +Add `unstable_serverBundles` option to Vite plugin to support splitting server code into multiple request handlers. + +This is an advanced feature designed for hosting provider integrations. When compiling your app into multiple server bundles, there will need to be a custom routing layer in front of your app directing requests to the correct bundle. This feature is currently unstable and only designed to gather early feedback. + +**Example usage:** + +```ts +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix({ + unstable_serverBundles: ({ branch }) => { + const isAuthenticatedRoute = branch.some( + (route) => route.id === "routes/_authenticated" + ); + + return isAuthenticatedRoute + ? "authenticated" + : "unauthenticated"; + }, + }), + ], +}); +``` diff --git a/docs/future/server-bundles.md b/docs/future/server-bundles.md new file mode 100644 index 00000000000..f385d528e3f --- /dev/null +++ b/docs/future/server-bundles.md @@ -0,0 +1,52 @@ +--- +title: Server Bundles (Unstable) +--- + +# Server Bundles (Unstable) + +This is an advanced feature designed for hosting provider integrations. When compiling your app into multiple server bundles, there will need to be a custom routing layer in front of your app directing requests to the correct bundle. This feature is currently unstable and only designed to gather early feedback. + +Remix typically builds your server code into a bundle that exposes a single request handler function. However, there are some scenarios where you might want to split your route tree into multiple server bundles that expose a request handler function for a subset of routes. To provide this level of flexibility, the [Remix Vite plugin][remix-vite] supports an `unstable_serverBundles` option which is a function for assigning routes to different server bundles. + +The provided `unstable_serverBundles` function is called for each route in the tree (except for routes that aren't addressable, e.g. pathless layout routes) and returns a server bundle ID that you'd like to assign it to. These bundle IDs will be used as directory names in your server build directory. + +For each route, this function is passed an array of routes leading to and including that route, referred to as the route `branch`. This allows you to create server bundles for different portions of the route tree. For example, you could use this to create a separate server bundle containing all routes within a particular layout route: + +```ts filename=vite.config.ts lines=[7-15] +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix({ + unstable_serverBundles: ({ branch }) => { + const isAuthenticatedRoute = branch.some( + (route) => route.id === "routes/_authenticated" + ); + + return isAuthenticatedRoute + ? "authenticated" + : "unauthenticated"; + }, + }), + ], +}); +``` + +Each `route` in the `branch` array contains the following properties: + +- `id` — The unique ID for this route, named like its `file` but relative to the app directory and without the extension, e.g. `app/routes/gists.$username.tsx` will have an `id` of `routes/gists.$username`. +- `path` — The path this route uses to match on the URL pathname. +- `file` — The absolute path to the entry point for this route. +- `index` — Whether or not this route is an index route. + +## Server bundle manifest + +When the build is complete, Remix will generate a `bundles.json` manifest file in your server build directory containing an object with the following properties: + +- `serverBundles` — An object that maps bundle IDs to the bundle's `id` and `file`. +- `routeIdToServerBundleId` — An object that maps route IDs to its server bundle ID. +- `routes` — A route manifest that maps route IDs to route metadata. This can be used to drive a custom routing layer in front of your Remix request handlers. + +[remix-vite]: ./vite.md +[pathless-layout-route]: ../file-conventions/routes#nested-layouts-without-nested-urls diff --git a/docs/future/vite.md b/docs/future/vite.md index 15a77df7db2..ce4bb44fca4 100644 --- a/docs/future/vite.md +++ b/docs/future/vite.md @@ -34,15 +34,7 @@ These templates include a `vite.config.ts` file which is where the Remix Vite pl ## Configuration -The Vite plugin does not use [`remix.config.js`][remix-config]. Instead, the plugin directly accepts the following subset of Remix config options: - -- [appDirectory][app-directory] -- [assetsBuildDirectory][assets-build-directory] -- [ignoredRouteFiles][ignored-route-files] -- [publicPath][public-path] -- [routes][routes] -- [serverBuildPath][server-build-path] -- [serverModuleFormat][server-module-format] +The Vite plugin does not use [`remix.config.js`][remix-config]. Instead, the plugin accepts options directly. For example, to configure `ignoredRouteFiles`: @@ -61,6 +53,32 @@ 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. +#### Supported Remix config options + +The following subset of Remix config options are supported: + +- [appDirectory][app-directory] +- [assetsBuildDirectory][assets-build-directory] +- [ignoredRouteFiles][ignored-route-files] +- [publicPath][public-path] +- [routes][routes] +- [serverBuildPath][server-build-path] +- [serverModuleFormat][server-module-format] + +The Vite plugin also accepts the following additional options: + +#### serverBuildDirectory + +The path to the server build directory, relative to the project root. Defaults to `"build/server"`. + +#### serverBuildFile + +The name of the server file generated in the server build directory. Defaults to `"index.js"`. + +#### unstable_serverBundles + +A function for assigning addressable routes to [server bundles][server-bundles]. + ## 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. @@ -74,7 +92,7 @@ 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"` +- [serverBuildPath][server-build-path] has been split into `serverBuildDirectory` and `serverBuildFile`, with the equivalent default for `serverBuildDirectory` being `"build/server"` rather than `"build"` ## Additional features & plugins @@ -875,3 +893,4 @@ We're definitely late to the Vite party, but we're excited to be here now! [server-dependencies-to-bundle]: https://remix.run/docs/en/main/file-conventions/remix-config#serverdependenciestobundle [blues-stack]: https://github.com/remix-run/blues-stack [global-node-polyfills]: ../other-api/node#polyfills +[server-bundles]: ./server-bundles diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 3f82cbf3938..0a6978d88df 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -16,6 +16,7 @@ const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); export const VITE_CONFIG = async (args: { port: number; + pluginOptions?: string; vitePlugins?: string; }) => { let hmrPort = await getPort(); @@ -31,7 +32,7 @@ export const VITE_CONFIG = async (args: { port: ${hmrPort} } }, - plugins: [remix(),${args.vitePlugins ?? ""}], + plugins: [remix(${args.pluginOptions}),${args.vitePlugins ?? ""}], }); `; }; @@ -136,14 +137,19 @@ export const viteBuild = ({ cwd }: { cwd: string }) => { export const viteRemixServe = async ({ cwd, port, + serverBundle, }: { cwd: string; port: number; + serverBundle?: string; }) => { let nodeBin = process.argv[0]; let serveProc = spawn( nodeBin, - ["node_modules/@remix-run/serve/dist/cli.js", "build/server/index.js"], + [ + "node_modules/@remix-run/serve/dist/cli.js", + `build/server/${serverBundle ? serverBundle + "/" : ""}index.js`, + ], { cwd, stdio: "pipe", diff --git a/integration/vite-server-bundles-test.ts b/integration/vite-server-bundles-test.ts new file mode 100644 index 00000000000..5ab7f18f975 --- /dev/null +++ b/integration/vite-server-bundles-test.ts @@ -0,0 +1,367 @@ +import fs from "node:fs"; +import path from "node:path"; +import { type Page, test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createProject, + viteBuild, + viteRemixServe, + VITE_CONFIG, +} from "./helpers/vite.js"; + +const withBundleServer = async ( + cwd: string, + serverBundle: string, + callback: (port: number) => Promise +): Promise => { + let port = await getPort(); + let stop = await viteRemixServe({ cwd, port, serverBundle }); + await callback(port); + stop(); +}; + +const ROUTE_FILE_COMMENT = "// THIS IS A ROUTE FILE"; + +function createRoute(path: string) { + return { + [`app/routes/${path}`]: ` + ${ROUTE_FILE_COMMENT} + import { Links, Meta, Outlet, Scripts, LiveReload } from "@remix-run/react"; + import { useState, useEffect } from "react"; + + export default function Route() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + return ( + <> +
+ Route: ${path} + {mounted ? (Mounted) : null} +
+ + + ); + } + `, + }; +} + +const TEST_ROUTES = [ + "_index.tsx", + + // Bundle A has an index route + "bundle-a.tsx", + "bundle-a._index.tsx", + "bundle-a.route-a.tsx", + "bundle-a.route-b.tsx", + + // Bundle B doesn't have an index route + "bundle-b.tsx", + "bundle-b.route-a.tsx", + "bundle-b.route-b.tsx", + + // Bundle C is nested in a pathless route + "_pathless.tsx", + "_pathless.bundle-c.tsx", + "_pathless.bundle-c.route-a.tsx", + "_pathless.bundle-c.route-b.tsx", +]; + +const files = { + "app/root.tsx": ` + ${ROUTE_FILE_COMMENT} + import { Links, Meta, Outlet, Scripts, LiveReload } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + + ); + } + `, + ...Object.assign({}, ...TEST_ROUTES.map(createRoute)), +}; + +const expectRenderedRoutes = async (page: Page, routeFiles: string[]) => { + await Promise.all( + TEST_ROUTES.map(async (routeFile) => { + let locator = page.locator( + `[data-route-file="${routeFile}"] [data-mounted]` + ); + if (routeFiles.includes(routeFile)) { + await expect(locator).toBeAttached(); + } else { + // Assert no other routes are rendered + await expect(locator).not.toBeAttached(); + } + }) + ); +}; + +test.describe(() => { + let cwd: string; + + test.beforeAll(async () => { + cwd = await createProject({ + "vite.config.ts": await VITE_CONFIG({ + port: -1, // Port only used for dev but we're testing the build + pluginOptions: `{ + unstable_serverBundles: async ({ branch }) => { + // Smoke test to ensure we can read the route files via 'route.file' + await Promise.all(branch.map(async (route) => { + const fs = await import("node:fs/promises"); + const routeFileContents = await fs.readFile(route.file, "utf8"); + if (!routeFileContents.includes(${JSON.stringify( + ROUTE_FILE_COMMENT + )})) { + throw new Error("Couldn't file route file test comment"); + } + })); + + if (branch.some((route) => route.id === "routes/_index")) { + return "root"; + } + + if (branch.some((route) => route.id === "routes/bundle-a")) { + return "bundle-a"; + } + + if (branch.some((route) => route.id === "routes/bundle-b")) { + return "bundle-b"; + } + + if (branch.some((route) => route.id === "routes/_pathless.bundle-c")) { + return "bundle-c"; + } + + throw new Error("No bundle defined for route " + branch[branch.length - 1].id); + } + }`, + }), + ...files, + }); + + await viteBuild({ cwd }); + }); + + test("Vite / server bundles", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await withBundleServer(cwd, "root", async (port) => { + await page.goto(`http://localhost:${port}/`); + await expectRenderedRoutes(page, ["_index.tsx"]); + + let _404s = ["/bundle-a", "/bundle-b", "/bundle-c"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + await withBundleServer(cwd, "bundle-a", async (port) => { + await page.goto(`http://localhost:${port}/bundle-a`); + await expectRenderedRoutes(page, ["bundle-a.tsx", "bundle-a._index.tsx"]); + + await page.goto(`http://localhost:${port}/bundle-a/route-a`); + await expectRenderedRoutes(page, [ + "bundle-a.tsx", + "bundle-a.route-a.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-a/route-b`); + await expectRenderedRoutes(page, [ + "bundle-a.tsx", + "bundle-a.route-b.tsx", + ]); + + let _404s = ["/bundle-b", "/bundle-c"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + await withBundleServer(cwd, "bundle-b", async (port) => { + await page.goto(`http://localhost:${port}/bundle-b`); + await expectRenderedRoutes(page, ["bundle-b.tsx"]); + + await page.goto(`http://localhost:${port}/bundle-b/route-a`); + await expectRenderedRoutes(page, [ + "bundle-b.tsx", + "bundle-b.route-a.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-b/route-b`); + await expectRenderedRoutes(page, [ + "bundle-b.tsx", + "bundle-b.route-b.tsx", + ]); + + let _404s = ["/bundle-a", "/bundle-c"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + await withBundleServer(cwd, "bundle-c", async (port) => { + await page.goto(`http://localhost:${port}/bundle-c`); + await expectRenderedRoutes(page, [ + "_pathless.tsx", + "_pathless.bundle-c.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-c/route-a`); + await expectRenderedRoutes(page, [ + "_pathless.tsx", + "_pathless.bundle-c.tsx", + "_pathless.bundle-c.route-a.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-c/route-b`); + await expectRenderedRoutes(page, [ + "_pathless.tsx", + "_pathless.bundle-c.tsx", + "_pathless.bundle-c.route-b.tsx", + ]); + + let _404s = ["/bundle-a", "/bundle-b"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + expect(pageErrors).toHaveLength(0); + }); + + test("Vite / server bundles / manifest", async () => { + expect( + JSON.parse( + fs.readFileSync(path.join(cwd, "build/server/bundles.json"), "utf8") + ) + ).toEqual({ + serverBundles: { + "bundle-c": { + id: "bundle-c", + file: "build/server/bundle-c/index.js", + }, + "bundle-a": { + id: "bundle-a", + file: "build/server/bundle-a/index.js", + }, + "bundle-b": { + id: "bundle-b", + file: "build/server/bundle-b/index.js", + }, + root: { + id: "root", + file: "build/server/root/index.js", + }, + }, + routeIdToBundleId: { + "routes/_pathless.bundle-c.route-a": "bundle-c", + "routes/_pathless.bundle-c.route-b": "bundle-c", + "routes/_pathless.bundle-c": "bundle-c", + "routes/bundle-a.route-a": "bundle-a", + "routes/bundle-a.route-b": "bundle-a", + "routes/bundle-b.route-a": "bundle-b", + "routes/bundle-b.route-b": "bundle-b", + "routes/bundle-a._index": "bundle-a", + "routes/bundle-b": "bundle-b", + "routes/_index": "root", + }, + routes: { + root: { + path: "", + id: "root", + file: "app/root.tsx", + }, + "routes/_pathless.bundle-c.route-a": { + file: "app/routes/_pathless.bundle-c.route-a.tsx", + id: "routes/_pathless.bundle-c.route-a", + path: "route-a", + parentId: "routes/_pathless.bundle-c", + }, + "routes/_pathless.bundle-c.route-b": { + file: "app/routes/_pathless.bundle-c.route-b.tsx", + id: "routes/_pathless.bundle-c.route-b", + path: "route-b", + parentId: "routes/_pathless.bundle-c", + }, + "routes/_pathless.bundle-c": { + file: "app/routes/_pathless.bundle-c.tsx", + id: "routes/_pathless.bundle-c", + path: "bundle-c", + parentId: "routes/_pathless", + }, + "routes/bundle-a.route-a": { + file: "app/routes/bundle-a.route-a.tsx", + id: "routes/bundle-a.route-a", + path: "route-a", + parentId: "routes/bundle-a", + }, + "routes/bundle-a.route-b": { + file: "app/routes/bundle-a.route-b.tsx", + id: "routes/bundle-a.route-b", + path: "route-b", + parentId: "routes/bundle-a", + }, + "routes/bundle-b.route-a": { + file: "app/routes/bundle-b.route-a.tsx", + id: "routes/bundle-b.route-a", + path: "route-a", + parentId: "routes/bundle-b", + }, + "routes/bundle-b.route-b": { + file: "app/routes/bundle-b.route-b.tsx", + id: "routes/bundle-b.route-b", + path: "route-b", + parentId: "routes/bundle-b", + }, + "routes/bundle-a._index": { + file: "app/routes/bundle-a._index.tsx", + id: "routes/bundle-a._index", + index: true, + parentId: "routes/bundle-a", + }, + "routes/_pathless": { + file: "app/routes/_pathless.tsx", + id: "routes/_pathless", + parentId: "root", + }, + "routes/bundle-a": { + file: "app/routes/bundle-a.tsx", + id: "routes/bundle-a", + path: "bundle-a", + parentId: "root", + }, + "routes/bundle-b": { + file: "app/routes/bundle-b.tsx", + id: "routes/bundle-b", + path: "bundle-b", + parentId: "root", + }, + "routes/_index": { + file: "app/routes/_index.tsx", + id: "routes/_index", + index: true, + parentId: "root", + }, + }, + }); + }); +}); diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts index 5aae527544c..596635ff0da 100644 --- a/packages/remix-dev/cli/commands.ts +++ b/packages/remix-dev/cli/commands.ts @@ -135,9 +135,13 @@ export async function build( } export async function viteBuild( - root: string, + root?: string, options: ViteBuildOptions = {} ): Promise { + if (!root) { + root = process.env.REMIX_ROOT || process.cwd(); + } + let { build } = await import("../vite/build"); await build(root, options); } diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index 4dbdb3faf21..7785e0c8587 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -6,4 +6,5 @@ export * as cli from "./cli/index"; export type { Manifest as AssetsManifest } from "./manifest"; export { getDependenciesToBundle } from "./dependencies"; +export type { Unstable_ServerBundlesManifest } from "./vite"; export { unstable_vitePlugin } from "./vite"; diff --git a/packages/remix-dev/vite/build.ts b/packages/remix-dev/vite/build.ts index 6de9f77eec2..ae2439606ef 100644 --- a/packages/remix-dev/vite/build.ts +++ b/packages/remix-dev/vite/build.ts @@ -1,10 +1,18 @@ import type * as Vite from "vite"; +import path from "node:path"; +import fse from "fs-extra"; import colors from "picocolors"; +import { + type ResolvedRemixVitePluginConfig, + type ServerBuildConfig, + configRouteToBranchRoute, +} from "./plugin"; +import type { ConfigRoute, RouteManifest } from "../config/routes"; +import invariant from "../invariant"; import { preloadViteEsm } from "./import-vite-esm-sync"; -import type { ResolvedRemixVitePluginConfig } from "./plugin"; -async function extractRemixPluginConfig({ +async function extractConfig({ configFile, mode, root, @@ -12,7 +20,7 @@ async function extractRemixPluginConfig({ configFile?: string; mode?: string; root: string; -}): Promise { +}) { let vite = await import("vite"); // Leverage the Vite config as a way to configure the entire multi-step build @@ -30,7 +38,154 @@ async function extractRemixPluginConfig({ process.exit(1); } - return pluginConfig; + return { pluginConfig, viteConfig }; +} + +function getAddressableRoutes(routes: RouteManifest): ConfigRoute[] { + let nonAddressableIds = new Set(); + + for (let id in routes) { + let route = routes[id]; + + // We omit the parent route of index routes since the index route takes ownership of its parent's path + if (route.index) { + invariant( + route.parentId, + `Expected index route "${route.id}" to have "parentId" set` + ); + nonAddressableIds.add(route.parentId); + } + + // We omit pathless routes since they can only be addressed via descendant routes + if (typeof route.path !== "string" && !route.index) { + nonAddressableIds.add(id); + } + } + + return Object.values(routes).filter( + (route) => !nonAddressableIds.has(route.id) + ); +} + +function getRouteBranch(routes: RouteManifest, routeId: string) { + let branch: ConfigRoute[] = []; + let currentRouteId: string | undefined = routeId; + + while (currentRouteId) { + let route: ConfigRoute = routes[currentRouteId]; + invariant(route, `Missing route for ${currentRouteId}`); + branch.push(route); + currentRouteId = route.parentId; + } + + return branch.reverse(); +} + +export type ServerBundlesManifest = { + serverBundles: { + [serverBundleId: string]: { + id: string; + file: string; + }; + }; + routeIdToBundleId: Record; + routes: RouteManifest; +}; + +async function getServerBuilds({ + routes, + serverBuildDirectory, + serverBuildFile, + serverBundles, + rootDirectory, + appDirectory, +}: ResolvedRemixVitePluginConfig): Promise<{ + serverBuilds: ServerBuildConfig[]; + serverBundlesManifest?: ServerBundlesManifest; +}> { + if (!serverBundles) { + return { serverBuilds: [{ routes, serverBuildDirectory }] }; + } + + let { normalizePath } = await import("vite"); + + let resolvedAppDirectory = path.resolve(rootDirectory, appDirectory); + let rootRelativeRoutes = Object.fromEntries( + Object.entries(routes).map(([id, route]) => { + let filePath = path.join(resolvedAppDirectory, route.file); + let rootRelativeFilePath = normalizePath( + path.relative(rootDirectory, filePath) + ); + return [id, { ...route, file: rootRelativeFilePath }]; + }) + ); + + let serverBundlesManifest: ServerBundlesManifest = { + serverBundles: {}, + routeIdToBundleId: {}, + routes: rootRelativeRoutes, + }; + + let serverBuildConfigByBundleId = new Map(); + + await Promise.all( + getAddressableRoutes(routes).map(async (route) => { + let branch = getRouteBranch(routes, route.id); + let bundleId = await serverBundles({ + branch: branch.map((route) => + configRouteToBranchRoute({ + ...route, + // Ensure absolute paths are passed to the serverBundles function + file: path.join(resolvedAppDirectory, route.file), + }) + ), + }); + if (typeof bundleId !== "string") { + throw new Error( + `The "unstable_serverBundles" function must return a string` + ); + } + serverBundlesManifest.routeIdToBundleId[route.id] = bundleId; + + let serverBundleDirectory = path.join(serverBuildDirectory, bundleId); + let serverBuildConfig = serverBuildConfigByBundleId.get(bundleId); + if (!serverBuildConfig) { + serverBundlesManifest.serverBundles[bundleId] = { + id: bundleId, + file: normalizePath( + path.join(serverBundleDirectory, serverBuildFile) + ), + }; + serverBuildConfig = { + routes: {}, + serverBuildDirectory: serverBundleDirectory, + }; + serverBuildConfigByBundleId.set(bundleId, serverBuildConfig); + } + for (let route of branch) { + serverBuildConfig.routes[route.id] = route; + } + }) + ); + + return { + serverBuilds: Array.from(serverBuildConfigByBundleId.values()), + serverBundlesManifest, + }; +} + +async function cleanServerBuildDirectory( + viteConfig: Vite.ResolvedConfig, + { rootDirectory, serverBuildDirectory }: ResolvedRemixVitePluginConfig +) { + let isWithinRoot = () => { + let relativePath = path.relative(rootDirectory, serverBuildDirectory); + return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + }; + + if (viteConfig.build.emptyOutDir ?? isWithinRoot()) { + await fse.remove(serverBuildDirectory); + } } export interface ViteBuildOptions { @@ -61,10 +216,7 @@ export async function build( // so it can be accessed synchronously via `importViteEsmSync` await preloadViteEsm(); - // For now we just use this function to validate that the Vite config is - // targeting Remix, but in the future the return value can be used to - // configure the entire multi-step build process. - await extractRemixPluginConfig({ + let { pluginConfig, viteConfig } = await extractConfig({ configFile, mode, root, @@ -72,7 +224,8 @@ export async function build( let vite = await import("vite"); - async function viteBuild({ ssr }: { ssr: boolean }) { + async function viteBuild(serverBuildConfig?: ServerBuildConfig) { + let ssr = Boolean(serverBuildConfig); await vite.build({ root, mode, @@ -81,9 +234,33 @@ export async function build( optimizeDeps: { force }, clearScreen, logLevel, + ...(serverBuildConfig + ? { __remixServerBuildConfig: serverBuildConfig } + : {}), }); } - await viteBuild({ ssr: false }); - await viteBuild({ ssr: true }); + // Since we're potentially running multiple Vite server builds with different + // output directories, we need to clean the root server build directory + // ourselves rather than relying on Vite to do it, otherwise you can end up + // with stale server bundle directories in your build output + await cleanServerBuildDirectory(viteConfig, pluginConfig); + + // Run the Vite client build first + await viteBuild(); + + // Then run Vite SSR builds in parallel + let { serverBuilds, serverBundlesManifest } = await getServerBuilds( + pluginConfig + ); + + await Promise.all(serverBuilds.map(viteBuild)); + + if (serverBundlesManifest) { + await fse.writeFile( + path.join(pluginConfig.serverBuildDirectory, "bundles.json"), + JSON.stringify(serverBundlesManifest, null, 2), + "utf-8" + ); + } } diff --git a/packages/remix-dev/vite/index.ts b/packages/remix-dev/vite/index.ts index 1bcb08bb692..91344d70e29 100644 --- a/packages/remix-dev/vite/index.ts +++ b/packages/remix-dev/vite/index.ts @@ -2,6 +2,7 @@ // don't need to have Vite installed as a peer dependency. Only types should // be imported at the top level. import type { RemixVitePlugin } from "./plugin"; +export type { ServerBundlesManifest as Unstable_ServerBundlesManifest } from "./build"; export const unstable_vitePlugin: RemixVitePlugin = (...args) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index c7283104f32..8300b5ce498 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -17,7 +17,7 @@ import jsesc from "jsesc"; import pick from "lodash/pick"; import colors from "picocolors"; -import { type RouteManifest } from "../config/routes"; +import { type ConfigRoute, type RouteManifest } from "../config/routes"; import { type AppConfig as RemixUserConfig, type RemixConfig as ResolvedRemixConfig, @@ -40,7 +40,6 @@ const supportedRemixConfigKeys = [ "ignoredRouteFiles", "publicPath", "routes", - "serverBuildPath", "serverModuleFormat", ] as const satisfies ReadonlyArray; type SupportedRemixConfigKey = typeof supportedRemixConfigKeys[number]; @@ -76,16 +75,46 @@ type RemixConfigJsdocOverrides = { * `"/"`. 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"]; }; +// Only expose a subset of route properties to the "serverBundles" function +const branchRouteProperties = [ + "id", + "path", + "file", + "index", +] as const satisfies ReadonlyArray; +type BranchRoute = Pick; + +export const configRouteToBranchRoute = ( + configRoute: ConfigRoute +): BranchRoute => pick(configRoute, branchRouteProperties); + +type ServerBundlesFunction = (args: { + branch: BranchRoute[]; +}) => string | Promise; + export type RemixVitePluginOptions = RemixConfigJsdocOverrides & - Omit; + Omit & { + /** + * The path to the server build directory, relative to the project. This + * directory should be deployed to your server. Defaults to + * `"build/server"`. + */ + serverBuildDirectory?: string; + /** + * The file name of the server build output. This file + * should end in a `.js` extension and should be deployed to your server. + * Defaults to `"index.js"`. + */ + serverBuildFile?: string; + /** + * A function for assigning routes to different server bundles. This + * function should return a server bundle ID which will be used as the + * bundle's directory name within the server build directory. + */ + unstable_serverBundles?: ServerBundlesFunction; + }; export type ResolvedRemixVitePluginConfig = Pick< ResolvedRemixConfig, @@ -98,9 +127,17 @@ export type ResolvedRemixVitePluginConfig = Pick< | "publicPath" | "relativeAssetsBuildDirectory" | "routes" - | "serverBuildPath" | "serverModuleFormat" ->; +> & { + serverBuildDirectory: string; + serverBuildFile: string; + serverBundles?: ServerBundlesFunction; +}; + +export type ServerBuildConfig = { + routes: RouteManifest; + serverBuildDirectory: string; +}; let serverBuildId = VirtualModule.id("server-build"); let serverManifestId = VirtualModule.id("server-manifest"); @@ -111,9 +148,8 @@ let injectHmrRuntimeId = VirtualModule.id("inject-hmr-runtime"); const isJsFile = (filePath: string) => /\.[cm]?[jt]sx?$/i.test(filePath); -type Route = RouteManifest[string]; const resolveRelativeRouteFilePath = ( - route: Route, + route: ConfigRoute, pluginConfig: ResolvedRemixVitePluginConfig ) => { let vite = importViteEsmSync(); @@ -301,22 +337,43 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { let viteChildCompiler: Vite.ViteDevServer | null = null; let cachedPluginConfig: ResolvedRemixVitePluginConfig | undefined; + let resolveServerBuildConfig = (): ServerBuildConfig | null => { + if ( + !("__remixServerBuildConfig" in viteUserConfig) || + !viteUserConfig.__remixServerBuildConfig + ) { + return null; + } + + let { routes, serverBuildDirectory } = + viteUserConfig.__remixServerBuildConfig as ServerBuildConfig; + + // Ensure extra config values can't sneak through + return { routes, serverBuildDirectory }; + }; + let resolvePluginConfig = async (): Promise => { - let defaults: Partial = { - serverBuildPath: "build/server/index.js", + let defaults = { assetsBuildDirectory: "build/client", + serverBuildDirectory: "build/server", + serverBuildFile: "index.js", publicPath: "/", - }; + } as const satisfies Partial; - let config = { + let pluginConfig = { ...defaults, - ...pick(options, supportedRemixConfigKeys), // Avoid leaking any config options that the Vite plugin doesn't support + ...options, }; let rootDirectory = viteUserConfig.root ?? process.env.REMIX_ROOT ?? process.cwd(); + let resolvedRemixConfig = await resolveConfig( + pick(pluginConfig, supportedRemixConfigKeys), + { rootDirectory } + ); + // Only select the Remix config options that the Vite plugin uses let { appDirectory, @@ -325,11 +382,17 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { publicPath, routes, entryServerFilePath, - serverBuildPath, + serverBuildDirectory, + serverBuildFile, + unstable_serverBundles, serverModuleFormat, relativeAssetsBuildDirectory, future, - } = await resolveConfig(config, { rootDirectory }); + } = { + ...pluginConfig, + ...resolvedRemixConfig, + ...resolveServerBuildConfig(), + }; return { appDirectory, @@ -339,7 +402,9 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { publicPath, routes, entryServerFilePath, - serverBuildPath, + serverBuildDirectory, + serverBuildFile, + serverBundles: unstable_serverBundles, serverModuleFormat, relativeAssetsBuildDirectory, future, @@ -617,15 +682,13 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { 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), + outDir: pluginConfig.serverBuildDirectory, rollupOptions: { ...viteUserConfig.build?.rollupOptions, preserveEntrySignatures: "exports-only", input: serverBuildId, output: { - entryFileNames: path.basename( - pluginConfig.serverBuildPath - ), + entryFileNames: pluginConfig.serverBuildFile, format: pluginConfig.serverModuleFormat, }, }, @@ -831,11 +894,10 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { invariant(cachedPluginConfig); invariant(viteConfig); - let { assetsBuildDirectory, serverBuildPath, rootDirectory } = + let { assetsBuildDirectory, serverBuildDirectory, rootDirectory } = cachedPluginConfig; - let serverBuildDir = path.dirname(serverBuildPath); - let ssrViteManifest = await loadViteManifest(serverBuildDir); + let ssrViteManifest = await loadViteManifest(serverBuildDirectory); let clientViteManifest = await loadViteManifest(assetsBuildDirectory); let clientAssetPaths = new Set( @@ -858,7 +920,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { // unnecessary assets don't get deployed alongside the server code. let movedAssetPaths: string[] = []; for (let ssrAssetPath of ssrAssetPaths) { - let src = path.join(serverBuildDir, ssrAssetPath); + let src = path.join(serverBuildDirectory, ssrAssetPath); if (!clientAssetPaths.has(ssrAssetPath)) { let dest = path.join(assetsBuildDirectory, ssrAssetPath); await fse.move(src, dest); @@ -875,7 +937,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { ); await Promise.all( ssrCssPaths.map((cssPath) => - fse.remove(path.join(serverBuildDir, cssPath)) + fse.remove(path.join(serverBuildDirectory, cssPath)) ) ); @@ -1289,7 +1351,7 @@ if (import.meta.hot && !inWebWorker && window.__remixLiveReloadEnabled) { function getRoute( pluginConfig: ResolvedRemixVitePluginConfig, file: string -): Route | undefined { +): ConfigRoute | undefined { let vite = importViteEsmSync(); if (!file.startsWith(vite.normalizePath(pluginConfig.appDirectory))) return; let routePath = vite.normalizePath( @@ -1304,7 +1366,7 @@ function getRoute( async function getRouteMetadata( pluginConfig: ResolvedRemixVitePluginConfig, viteChildCompiler: Vite.ViteDevServer | null, - route: Route + route: ConfigRoute ) { let sourceExports = await getRouteModuleExports( viteChildCompiler,