=> {
+ 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,