From 44d1cc61069ec29cb271f50ea1be58c21979785e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 7 Feb 2024 01:36:19 +0900 Subject: [PATCH] feat(remix-dev/vite, remix-react, remix-server-runtime): support custom basename (#8145) Co-authored-by: Mark Dalgleish Co-authored-by: Matt Brophy --- .changeset/three-cars-dream.md | 6 + .changeset/vite-deprecate-public-path.md | 7 + .changeset/vite-rr-basename.md | 8 + docs/future/vite.md | 11 +- integration/helpers/vite.ts | 36 +- integration/vite-basename-test.ts | 519 ++++++++++++++++++ integration/vite-presets-test.ts | 1 + packages/remix-dev/server-build.ts | 1 + packages/remix-dev/vite/node-adapter.ts | 22 +- packages/remix-dev/vite/plugin.ts | 173 +++--- packages/remix-dev/vite/styles.ts | 2 +- .../remix-express/__tests__/server-test.ts | 2 +- packages/remix-express/server.ts | 3 +- packages/remix-react/browser.tsx | 2 + packages/remix-react/components.tsx | 12 +- packages/remix-server-runtime/build.ts | 4 + .../remix-server-runtime/routeMatching.ts | 6 +- packages/remix-server-runtime/server.ts | 16 +- .../remix-server-runtime/serverHandoff.ts | 1 + 19 files changed, 726 insertions(+), 106 deletions(-) create mode 100644 .changeset/three-cars-dream.md create mode 100644 .changeset/vite-deprecate-public-path.md create mode 100644 .changeset/vite-rr-basename.md create mode 100644 integration/vite-basename-test.ts diff --git a/.changeset/three-cars-dream.md b/.changeset/three-cars-dream.md new file mode 100644 index 00000000000..edc8b95ee91 --- /dev/null +++ b/.changeset/three-cars-dream.md @@ -0,0 +1,6 @@ +--- +"@remix-run/express": patch +--- + +Use `req.originalUrl` instead of `req.url` so that Remix sees the full URL +- Remix relies on the knowing the full URL to ensure that server and client code can function together, and does not support URL rewriting prior to the Remix handler diff --git a/.changeset/vite-deprecate-public-path.md b/.changeset/vite-deprecate-public-path.md new file mode 100644 index 00000000000..6809050f399 --- /dev/null +++ b/.changeset/vite-deprecate-public-path.md @@ -0,0 +1,7 @@ +--- +"@remix-run/dev": patch +--- + +Vite: Remove the ability to pass `publicPath` as an option to the Remix vite plugin + - ⚠️ **This is a breaking change for projects using the unstable Vite plugin with a `publicPath`** + - This is already handled in Vite via the [`base`](https://vitejs.dev/guide/build.html#public-base-path) config so we now set the Remix `publicPath` from the Vite `base` config \ No newline at end of file diff --git a/.changeset/vite-rr-basename.md b/.changeset/vite-rr-basename.md new file mode 100644 index 00000000000..f0c91df6b83 --- /dev/null +++ b/.changeset/vite-rr-basename.md @@ -0,0 +1,8 @@ +--- +"@remix-run/dev": minor +"@remix-run/express": minor +"@remix-run/react": minor +"@remix-run/server-runtime": minor +--- + +Vite: Add a new `basename` option to the Vite plugin, allowing users to set the internal React Router [`basename`](https://reactrouter.com/en/main/routers/create-browser-router#basename) in order to to serve their applications underneath a subpath diff --git a/docs/future/vite.md b/docs/future/vite.md index 7dbeb6e3d0e..79730380610 100644 --- a/docs/future/vite.md +++ b/docs/future/vite.md @@ -51,7 +51,6 @@ The following subset of Remix config options are supported: - [appDirectory][app-directory] - [future][future] - [ignoredRouteFiles][ignored-route-files] -- [publicPath][public-path] - [routes][routes] - [serverModuleFormat][server-module-format] @@ -62,6 +61,10 @@ The Vite plugin also accepts the following additional options: The path to the build directory, relative to the project root. Defaults to `"build"`. +#### basename + +An optional basename for your route paths, passed through to the React Router [`basename`][rr-basename] option. Please note that this is different from your _asset_ paths - you can configure those via the Vite [`base`][vite-base] flag. + #### buildEnd A function that is called after the full Remix build is complete. @@ -73,8 +76,7 @@ to `false`. #### presets -An array of [presets] to ease integration with -other tools and hosting providers. +An array of [presets] to ease integration with other tools and hosting providers. #### serverBuildFile @@ -260,6 +262,7 @@ In order to align the default Remix project structure with the way Vite works, t This also means that the following configuration defaults have been changed: - [publicPath][public-path] defaults to `"/"` rather than `"/build/"` + - `publicPath` is also no longer something you configure directly, instead it is set internally from the Vite [`base`][vite-base] config value. - [serverBuildPath][server-build-path] has been replaced by `serverBuildFile` which defaults to `"index.js"`. This file will be written into the server directory within your configured `buildDirectory`. ## Additional features & plugins @@ -1261,6 +1264,8 @@ We're definitely late to the Vite party, but we're excited to be here now! [cloudflare-proxy-cf]: https://github.com/cloudflare/workers-sdk/issues/4875 [cloudflare-proxy-ctx]: https://github.com/cloudflare/workers-sdk/issues/4876 [cloudflare-proxy-caches]: https://github.com/cloudflare/workers-sdk/issues/4879 +[rr-basename]: https://reactrouter.com/routers/create-browser-router#basename +[vite-base]: https://vitejs.dev/config/shared-options.html#base [how-fix-cjs-esm]: https://www.youtube.com/watch?v=jmNuEEtwkD4 [presets]: ./presets [vite-5-1-0-beta]: https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md#510-beta0-2024-01-15 diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 0334aa2b787..bfd02f3cbbd 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -122,10 +122,12 @@ export const viteRemixServe = async ({ cwd, port, serverBundle, + basename, }: { cwd: string; port: number; serverBundle?: string; + basename?: string; }) => { let nodeBin = process.argv[0]; let serveProc = spawn( @@ -140,26 +142,38 @@ export const viteRemixServe = async ({ env: { NODE_ENV: "production", PORT: port.toFixed(0) }, } ); - await waitForServer(serveProc, { port }); + await waitForServer(serveProc, { port, basename }); return () => serveProc.kill(); }; type ServerArgs = { cwd: string; port: number; + env?: Record; + basename?: string; }; const createDev = (nodeArgs: string[]) => - async ({ cwd, port }: ServerArgs): Promise<() => unknown> => { - let proc = node(nodeArgs, { cwd }); - await waitForServer(proc, { port }); + async ({ cwd, port, env, basename }: ServerArgs): Promise<() => unknown> => { + let proc = node(nodeArgs, { cwd, env }); + await waitForServer(proc, { port, basename }); return () => proc.kill(); }; export const viteDev = createDev([remixBin, "vite:dev"]); export const customDev = createDev(["./server.mjs"]); +// Used for testing errors thrown on build when we don't want to start and +// wait for the server +export const viteDevCmd = ({ cwd }: { cwd: string }) => { + let nodeBin = process.argv[0]; + return spawnSync(nodeBin, [remixBin, "vite:dev"], { + cwd, + env: { ...process.env }, + }); +}; + export const using = async ( cleanup: () => unknown | Promise, task: () => unknown | Promise @@ -171,12 +185,18 @@ export const using = async ( } }; -function node(args: string[], options: { cwd: string }) { +function node( + args: string[], + options: { cwd: string; env?: Record } +) { let nodeBin = process.argv[0]; let proc = spawn(nodeBin, args, { cwd: options.cwd, - env: process.env, + env: { + ...process.env, + ...options.env, + }, stdio: "pipe", }); return proc; @@ -184,13 +204,13 @@ function node(args: string[], options: { cwd: string }) { async function waitForServer( proc: ChildProcess & { stdout: Readable; stderr: Readable }, - args: { port: number } + args: { port: number; basename?: string } ) { let devStdout = bufferize(proc.stdout); let devStderr = bufferize(proc.stderr); await waitOn({ - resources: [`http://localhost:${args.port}/`], + resources: [`http://localhost:${args.port}${args.basename ?? "/"}`], timeout: 10000, }).catch((err) => { let stdout = devStdout(); diff --git a/integration/vite-basename-test.ts b/integration/vite-basename-test.ts new file mode 100644 index 00000000000..d41eaea8abe --- /dev/null +++ b/integration/vite-basename-test.ts @@ -0,0 +1,519 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createEditor, + createProject, + customDev, + viteBuild, + viteConfig, + viteDev, + viteDevCmd, + viteRemixServe, +} from "./helpers/vite.js"; +import { js } from "./helpers/create-fixture.js"; + +const files = { + "app/routes/_index.tsx": String.raw` + import { useState, useEffect } from "react"; + import { Link } from "@remix-run/react" + + export default function IndexRoute() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +
+

Index

+ +

Mounted: {mounted ? "yes" : "no"}

+

HMR updated: 0

+ other +
+ ); + } + `, + "app/routes/other.tsx": String.raw` + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return "other-loader"; + }; + + export default function OtherRoute() { + const loaderData = useLoaderData() + + return ( +
+

{loaderData}

+
+ ); + } + `, +}; + +async function viteConfigFile({ + port, + base, + basename, +}: { + port: number; + base?: string; + basename?: string; +}) { + return js` + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + + export default { + ${base !== "/" ? 'base: "' + base + '",' : ""} + ${await viteConfig.server({ port })} + plugins: [ + ${ + basename !== "/" + ? 'remix({ basename: "' + basename + '" }),' + : "remix()," + } + ] + } + `; +} + +const customServerFile = ({ + port, + base, + basename, +}: { + port: number; + base?: string; + basename?: string; +}) => { + base = base ?? "/mybase/"; + basename = basename ?? base; + + return String.raw` + import { createRequestHandler } from "@remix-run/express"; + import { installGlobals } from "@remix-run/node"; + import express from "express"; + installGlobals(); + + const viteDevServer = + process.env.NODE_ENV === "production" + ? undefined + : await import("vite").then(({ createServer }) => + createServer({ + server: { + middlewareMode: true, + }, + }) + ); + + const app = express(); + app.use("${base}", viteDevServer?.middlewares || express.static("build/client")); + app.all( + "${basename}*", + createRequestHandler({ + build: viteDevServer + ? () => viteDevServer.ssrLoadModule("virtual:remix/server-build") + : await import("./build/server/index.js"), + }) + ); + app.get("*", (_req, res) => { + res.setHeader("content-type", "text/html") + res.end('Remix app is at ${basename}'); + }); + + const port = ${port}; + app.listen(port, () => console.log('http://localhost:' + port)); + `; +}; + +test.describe("Vite base / Remix basename / Vite dev", () => { + let port: number; + let cwd: string; + let stop: () => unknown; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + ...files, + }); + if (startServer !== false) { + stop = await viteDev({ cwd, port, basename }); + } + } + + test.afterAll(async () => await stop()); + + test("works when the base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowDev({ page, cwd, port }); + }); + + test("works when the base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowDev({ page, cwd, port, basename: "/mybase/app/" }); + }); + + test("errors if basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + startServer: false, + }); + let proc = await viteDevCmd({ cwd }); + expect(proc.stderr.toString()).toMatch( + "Error: When using the Remix `basename` and the Vite `base` config, the " + + "`basename` config must begin with `base` for the default Vite dev server." + ); + }); +}); + +test.describe("Vite base / Remix basename / express dev", async () => { + let port: number; + let cwd: string; + let stop: () => void; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + "server.mjs": customServerFile({ port, basename }), + ...files, + }); + if (startServer !== false) { + stop = await customDev({ cwd, port, basename }); + } + } + + test.afterAll(() => stop()); + + test("works when base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowDev({ page, cwd, port }); + }); + + test("works when base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowDev({ page, cwd, port, basename: "/mybase/app/" }); + }); + + test("works when basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + }); + await workflowDev({ page, cwd, port, basename: "/notmybase/" }); + }); +}); + +async function workflowDev({ + page, + cwd, + port, + base, + basename, +}: { + page: Page; + cwd: string; + port: number; + base?: string; + basename?: string; +}) { + base = base ?? "/mybase/"; + basename = basename ?? base; + + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + let edit = createEditor(cwd); + + let requestUrls: string[] = []; + page.on("request", (request) => { + requestUrls.push(request.url()); + }); + + // setup: initial render at basename + await page.goto(`http://localhost:${port}${basename}`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // setup: hydration + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes" + ); + + // setup: browser state + let hmrStatus = page.locator("#index [data-hmr]"); + await expect(hmrStatus).toHaveText("HMR updated: 0"); + let input = page.locator("#index input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + expect(pageErrors).toEqual([]); + + // route: HMR + await edit("app/routes/_index.tsx", (contents) => + contents.replace("HMR updated: 0", "HMR updated: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 1"); + await expect(input).toHaveValue("stateful"); + expect(pageErrors).toEqual([]); + + // client side navigation + await page.getByRole("link", { name: "other" }).click(); + await page.waitForURL(`http://localhost:${port}${basename}other`); + await page.getByText("other-loader").click(); + expect(pageErrors).toEqual([]); + + let isAssetRequest = (url: string) => + /\.[jt]sx?/.test(url) || + /@id\/__x00__virtual:/.test(url) || + /@vite\/client/.test(url) || + /node_modules\/vite\/dist\/client\/env/.test(url); + + // verify client asset requests are all under base + expect( + requestUrls + .filter((url) => isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${base}`)) + ).toBe(true); + + // verify client route requests are all under basename + expect( + requestUrls + .filter((url) => !isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${basename}`)) + ).toBe(true); +} + +test.describe("Vite base / Remix basename / vite build", () => { + let port: number; + let cwd: string; + let stop: () => unknown; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + ...files, + }); + viteBuild({ cwd }); + if (startServer !== false) { + stop = await viteRemixServe({ cwd, port, basename }); + } + } + + test.afterAll(() => stop()); + + test("works when base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowBuild({ page, port }); + }); + + test("works when base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowBuild({ page, port, basename: "/mybase/app/" }); + }); + + test("works when basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + }); + await workflowBuild({ page, port, basename: "/notmybase/" }); + }); +}); + +test.describe("Vite base / Remix basename / express build", async () => { + let port: number; + let cwd: string; + let stop: () => void; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + "server.mjs": customServerFile({ port, base, basename }), + ...files, + }); + viteBuild({ cwd }); + if (startServer !== false) { + stop = await customDev({ + cwd, + port, + basename, + env: { NODE_ENV: "production" }, + }); + } + } + + test.afterAll(() => stop()); + + test("works when base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowBuild({ page, port }); + }); + + test("works when base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowBuild({ page, port, basename: "/mybase/app/" }); + }); + + test("works when basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + }); + await workflowBuild({ page, port, basename: "/notmybase/" }); + }); + + test("works when when base is an absolute external URL", async ({ page }) => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ + port, + base: "https://cdn.example.com/assets/", + basename: "/app/", + }), + // Slim server that only serves basename (route) requests from the remix handler + "server.mjs": String.raw` + import { createRequestHandler } from "@remix-run/express"; + import { installGlobals } from "@remix-run/node"; + import express from "express"; + installGlobals(); + + const app = express(); + app.all( + "/app/*", + createRequestHandler({ build: await import("./build/server/index.js") }) + ); + + const port = ${port}; + app.listen(port, () => console.log('http://localhost:' + port)); + `, + ...files, + }); + + viteBuild({ cwd }); + stop = await customDev({ + cwd, + port, + basename: "/app/", + env: { NODE_ENV: "production" }, + }); + + // Intercept and make all CDN requests 404 + let requestUrls: string[] = []; + await page.route("**/*.js", (route) => { + requestUrls.push(route.request().url()); + route.fulfill({ status: 404 }); + }); + + // setup: initial render + await page.goto(`http://localhost:${port}/app/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // Can't validate hydration here due to 404s, but we can ensure assets are + // attempting to load from the CDN + expect( + requestUrls.length > 0 && + requestUrls.every((url) => + url.startsWith("https://cdn.example.com/assets/") + ) + ).toBe(true); + }); +}); + +async function workflowBuild({ + page, + port, + base, + basename, +}: { + page: Page; + port: number; + base?: string; + basename?: string; +}) { + base = base ?? "/mybase/"; + basename = basename ?? base; + + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + let requestUrls: string[] = []; + page.on("request", (request) => { + requestUrls.push(request.url()); + }); + + // setup: initial render + await page.goto(`http://localhost:${port}${basename}`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // setup: hydration + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes" + ); + + // client side navigation + await page.getByRole("link", { name: "other" }).click(); + await page.waitForURL(`http://localhost:${port}${basename}other`); + await page.getByText("other-loader").click(); + expect(pageErrors).toEqual([]); + + let isAssetRequest = (url: string) => /\.js/.test(url); + + // verify client asset requests are all under base + expect( + requestUrls + .filter((url) => isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${base}`)) + ).toBe(true); + + // verify client route requests are all under basename + expect( + requestUrls + .filter((url) => !isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${basename}`)) + ).toBe(true); +} diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index dd5ebcc2245..9e40d6bd611 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -157,6 +157,7 @@ test.describe(async () => { // Smoke test the resolved config expect(Object.keys(buildEndArgs.remixConfig)).toEqual([ "appDirectory", + "basename", "buildDirectory", "buildEnd", "future", diff --git a/packages/remix-dev/server-build.ts b/packages/remix-dev/server-build.ts index b87c176b4ea..226a3492048 100644 --- a/packages/remix-dev/server-build.ts +++ b/packages/remix-dev/server-build.ts @@ -8,6 +8,7 @@ throw new Error( export const mode: ServerBuild["mode"] = undefined!; export const assets: ServerBuild["assets"] = undefined!; +export const basename: ServerBuild["basename"] = undefined!; export const entry: ServerBuild["entry"] = undefined!; export const routes: ServerBuild["routes"] = undefined!; export const future: ServerBuild["future"] = undefined!; diff --git a/packages/remix-dev/vite/node-adapter.ts b/packages/remix-dev/vite/node-adapter.ts index 8abd0422b06..ad270a85525 100644 --- a/packages/remix-dev/vite/node-adapter.ts +++ b/packages/remix-dev/vite/node-adapter.ts @@ -1,17 +1,14 @@ -import type { - IncomingHttpHeaders, - IncomingMessage, - ServerResponse, -} from "node:http"; +import type { IncomingHttpHeaders, ServerResponse } from "node:http"; import { once } from "node:events"; import { Readable } from "node:stream"; import { splitCookiesString } from "set-cookie-parser"; import { createReadableStreamFromReadable } from "@remix-run/node"; +import type * as Vite from "vite"; import invariant from "../invariant"; export type NodeRequestHandler = ( - req: IncomingMessage, + req: Vite.Connect.IncomingMessage, res: ServerResponse ) => Promise; @@ -34,14 +31,19 @@ function fromNodeHeaders(nodeHeaders: IncomingHttpHeaders): Headers { } // Based on `createRemixRequest` in packages/remix-express/server.ts -export function fromNodeRequest(nodeReq: IncomingMessage): Request { +export function fromNodeRequest( + nodeReq: Vite.Connect.IncomingMessage +): Request { let origin = nodeReq.headers.origin && "null" !== nodeReq.headers.origin ? nodeReq.headers.origin : `http://${nodeReq.headers.host}`; - invariant(nodeReq.url, 'Expected "req.url" to be defined'); - let url = new URL(nodeReq.url, origin); - + // Use `req.originalUrl` so Remix is aware of the full path + invariant( + nodeReq.originalUrl, + "Expected `nodeReq.originalUrl` to be defined" + ); + let url = new URL(nodeReq.originalUrl, origin); let init: RequestInit = { method: nodeReq.method, headers: fromNodeHeaders(nodeReq.headers), diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 5cc94da4ba5..81c59cc382e 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -43,7 +43,6 @@ const supportedRemixEsbuildConfigKeys = [ "appDirectory", "future", "ignoredRouteFiles", - "publicPath", "routes", "serverModuleFormat", ] as const satisfies ReadonlyArray; @@ -67,16 +66,6 @@ const CLIENT_ROUTE_EXPORTS = [ const CLIENT_ROUTE_QUERY_STRING = "?client-route"; -// We need to provide different JSDoc comments in some cases due to differences -// between the Remix config and the Vite plugin. -type RemixEsbuildUserConfigJsdocOverrides = { - /** - * 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?: SupportedRemixEsbuildUserConfig["publicPath"]; -}; - // Only expose a subset of route properties to the "serverBundles" function const branchRouteProperties = [ "id", @@ -132,50 +121,50 @@ export type Preset = { }) => void | Promise; }; -export type VitePluginConfig = RemixEsbuildUserConfigJsdocOverrides & - Omit< - SupportedRemixEsbuildUserConfig, - keyof RemixEsbuildUserConfigJsdocOverrides - > & { - /** - * The path to the build directory, relative to the project. Defaults to - * `"build"`. - */ - buildDirectory?: string; - /** - * A function that is called after the full Remix build is complete. - */ - buildEnd?: BuildEndHook; - /** - * Whether to write a `"manifest.json"` file to the build directory.` - * Defaults to `false`. - */ - manifest?: boolean; - /** - * An array of Remix config presets to ease integration with other platforms - * and tools. - */ - presets?: Array; - /** - * 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. - */ - serverBundles?: ServerBundlesFunction; - /** - * Enable server-side rendering for your application. Disable to use Remix in - * "SPA Mode", which will request the `/` path at build-time and save it as - * an `index.html` file with your assets so your application can be deployed - * as a SPA without server-rendering. Default's to `true`. - */ - unstable_ssr?: boolean; - }; +export type VitePluginConfig = SupportedRemixEsbuildUserConfig & { + /** + * The react router app basename. Defaults to `"/"`. + */ + basename?: string; + /** + * The path to the build directory, relative to the project. Defaults to + * `"build"`. + */ + buildDirectory?: string; + /** + * A function that is called after the full Remix build is complete. + */ + buildEnd?: BuildEndHook; + /** + * Whether to write a `"manifest.json"` file to the build directory.` + * Defaults to `false`. + */ + manifest?: boolean; + /** + * An array of Remix config presets to ease integration with other platforms + * and tools. + */ + presets?: Array; + /** + * 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. + */ + serverBundles?: ServerBundlesFunction; + /** + * Enable server-side rendering for your application. Disable to use Remix in + * "SPA Mode", which will request the `/` path at build-time and save it as + * an `index.html` file with your assets so your application can be deployed + * as a SPA without server-rendering. Default's to `true`. + */ + unstable_ssr?: boolean; +}; type BuildEndHook = (args: { remixConfig: ResolvedVitePluginConfig; @@ -187,9 +176,11 @@ export type ResolvedVitePluginConfig = Readonly< ResolvedRemixEsbuildConfig, "appDirectory" | "future" | "publicPath" | "routes" | "serverModuleFormat" > & { + basename: string; buildDirectory: string; buildEnd?: BuildEndHook; manifest: boolean; + publicPath: string; // derived from Vite's `base` config serverBuildFile: string; serverBundles?: ServerBundlesFunction; unstable_ssr: boolean; @@ -564,9 +555,9 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { }); let defaults = { + basename: "/", buildDirectory: "build", manifest: false, - publicPath: "/", serverBuildFile: "index.js", unstable_ssr: true, } as const satisfies Partial; @@ -579,7 +570,8 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let rootDirectory = viteUserConfig.root ?? process.env.REMIX_ROOT ?? process.cwd(); - let { buildEnd, manifest, unstable_ssr } = resolvedRemixUserConfig; + let { basename, buildEnd, manifest, unstable_ssr } = + resolvedRemixUserConfig; let isSpaMode = !unstable_ssr; // Only select the Remix esbuild config options that the Vite plugin uses @@ -588,7 +580,6 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { entryClientFilePath, entryServerFilePath, future, - publicPath, routes, serverModuleFormat, } = await resolveRemixEsbuildConfig( @@ -603,6 +594,21 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let { serverBuildFile, serverBundles } = resolvedRemixUserConfig; + let publicPath = viteUserConfig.base ?? "/"; + + if ( + basename !== "/" && + viteCommand === "serve" && + !viteUserConfig.server?.middlewareMode && + !basename.startsWith(publicPath) + ) { + throw new Error( + "When using the Remix `basename` and the Vite `base` config, " + + "the `basename` config must begin with `base` for the default " + + "Vite dev server." + ); + } + // Log warning for incompatible vite config flags if (isSpaMode && serverBundles) { console.warn( @@ -626,6 +632,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let remixConfig: ResolvedVitePluginConfig = deepFreeze({ appDirectory, + basename, buildDirectory, buildEnd, future, @@ -704,6 +711,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { getClientBuildDirectory(ctx.remixConfig) ) )}; + export const basename = ${JSON.stringify(ctx.remixConfig.basename)}; export const future = ${JSON.stringify(ctx.remixConfig.future)}; export const isSpaMode = ${!ctx.remixConfig.unstable_ssr}; export const publicPath = ${JSON.stringify(ctx.remixConfig.publicPath)}; @@ -814,10 +822,13 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { path: route.path, index: route.index, caseSensitive: route.caseSensitive, - module: `${resolveFileUrl( - ctx, - resolveRelativeRouteFilePath(route, ctx.remixConfig) - )}${CLIENT_ROUTE_QUERY_STRING}`, + module: path.posix.join( + ctx.remixConfig.publicPath, + `${resolveFileUrl( + ctx, + resolveRelativeRouteFilePath(route, ctx.remixConfig) + )}${CLIENT_ROUTE_QUERY_STRING}` + ), hasAction: sourceExports.includes("action"), hasLoader: sourceExports.includes("loader"), hasClientAction: sourceExports.includes("clientAction"), @@ -829,12 +840,21 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { return { version: String(Math.random()), - url: VirtualModule.url(browserManifestId), + url: path.posix.join( + ctx.remixConfig.publicPath, + VirtualModule.url(browserManifestId) + ), hmr: { - runtime: VirtualModule.url(injectHmrRuntimeId), + runtime: path.posix.join( + ctx.remixConfig.publicPath, + VirtualModule.url(injectHmrRuntimeId) + ), }, entry: { - module: resolveFileUrl(ctx, ctx.entryClientFilePath), + module: path.posix.join( + ctx.remixConfig.publicPath, + resolveFileUrl(ctx, ctx.entryClientFilePath) + ), imports: [], }, routes, @@ -911,8 +931,8 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { "@remix-run/react", ], }, + base: viteUserConfig.base, ...(viteCommand === "build" && { - base: ctx.remixConfig.publicPath, build: { cssMinify: viteUserConfig.build?.cssMinify ?? true, ...(!viteConfigEnv.isSsrBuild @@ -1650,16 +1670,21 @@ async function getRouteMetadata( path: route.path, index: route.index, caseSensitive: route.caseSensitive, - url: + url: path.posix.join( + ctx.remixConfig.publicPath, "/" + - path.relative( - ctx.rootDirectory, + path.relative( + ctx.rootDirectory, + resolveRelativeRouteFilePath(route, ctx.remixConfig) + ) + ), + module: path.posix.join( + ctx.remixConfig.publicPath, + `${resolveFileUrl( + ctx, resolveRelativeRouteFilePath(route, ctx.remixConfig) - ), - module: `${resolveFileUrl( - ctx, - resolveRelativeRouteFilePath(route, ctx.remixConfig) - )}?import`, // Ensure the Vite dev server responds with a JS module + )}?import` + ), // Ensure the Vite dev server responds with a JS module hasAction: sourceExports.includes("action"), hasClientAction: sourceExports.includes("clientAction"), hasLoader: sourceExports.includes("loader"), diff --git a/packages/remix-dev/vite/styles.ts b/packages/remix-dev/vite/styles.ts index 6a9355123ec..f8691f051aa 100644 --- a/packages/remix-dev/vite/styles.ts +++ b/packages/remix-dev/vite/styles.ts @@ -195,7 +195,7 @@ export const getStylesForUrl = async ({ let routes = createRoutes(build.routes); let appPath = path.relative(process.cwd(), remixConfig.appDirectory); let documentRouteFiles = - matchRoutes(routes, url)?.map((match) => + matchRoutes(routes, url, build.basename)?.map((match) => path.join(appPath, remixConfig.routes[match.route.id].file) ) ?? []; diff --git a/packages/remix-express/__tests__/server-test.ts b/packages/remix-express/__tests__/server-test.ts index 79e27a580ff..41d493b0e77 100644 --- a/packages/remix-express/__tests__/server-test.ts +++ b/packages/remix-express/__tests__/server-test.ts @@ -35,7 +35,7 @@ function createApp() { // We don't have a real app to test, but it doesn't matter. We won't ever // call through to the real createRequestHandler // @ts-expect-error - createRequestHandler({ build: undefined }) + createRequestHandler({ build: {} }) ); return app; diff --git a/packages/remix-express/server.ts b/packages/remix-express/server.ts index 016cdfb1d9b..3c0d25bc404 100644 --- a/packages/remix-express/server.ts +++ b/packages/remix-express/server.ts @@ -94,7 +94,8 @@ export function createRemixRequest( let port = hostnamePort || hostPort; // Use req.hostname here as it respects the "trust proxy" setting let resolvedHost = `${req.hostname}${port ? `:${port}` : ""}`; - let url = new URL(`${req.protocol}://${resolvedHost}${req.url}`); + // Use `req.originalUrl` so Remix is aware of the full path + let url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`); // Abort action/loaders once we can no longer write a response let controller = new AbortController(); diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index c485deac240..357edc655d9 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -24,6 +24,7 @@ import { declare global { var __remixContext: { url: string; + basename?: string; state: HydrationState; criticalCss?: string; future: FutureConfig; @@ -267,6 +268,7 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { router = createRouter({ routes, history: createBrowserHistory(), + basename: window.__remixContext.basename, future: { v7_normalizeFormMethod: true, v7_fetcherPersist: window.__remixContext.future.v3_fetcherPersist, diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index aec1ba2dde6..b93baee5bb7 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -337,8 +337,8 @@ export function PrefetchPageLinks({ }: PrefetchPageDescriptor) { let { router } = useDataRouterContext(); let matches = React.useMemo( - () => matchRoutes(router.routes, page), - [router.routes, page] + () => matchRoutes(router.routes, page, router.basename), + [router.routes, page, router.basename] ); if (!matches) { @@ -852,7 +852,11 @@ import(${JSON.stringify(manifest.entry.module)});`; let nextMatches = React.useMemo(() => { if (navigation.location) { // FIXME: can probably use transitionManager `nextMatches` - let matches = matchRoutes(router.routes, navigation.location); + let matches = matchRoutes( + router.routes, + navigation.location, + router.basename + ); invariant( matches, `No routes match path "${navigation.location.pathname}"` @@ -861,7 +865,7 @@ import(${JSON.stringify(manifest.entry.module)});`; } return []; - }, [navigation.location, router.routes]); + }, [navigation.location, router.routes, router.basename]); let routePreloads = matches .concat(nextMatches) diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts index d3267cb2e1f..778b627ebb6 100644 --- a/packages/remix-server-runtime/build.ts +++ b/packages/remix-server-runtime/build.ts @@ -3,6 +3,9 @@ import type { AssetsManifest, EntryContext, FutureConfig } from "./entry"; import type { ServerRouteManifest } from "./routes"; import type { AppLoadContext } from "./data"; +// NOTE: IF you modify `ServerBuild`, be sure to modify the +// `remix-dev/server-build.ts` file to reflect the new field as well + /** * The output of the compiler for the server build. */ @@ -16,6 +19,7 @@ export interface ServerBuild { }; routes: ServerRouteManifest; assets: AssetsManifest; + basename?: string; publicPath: string; assetsBuildDirectory: string; future: FutureConfig; diff --git a/packages/remix-server-runtime/routeMatching.ts b/packages/remix-server-runtime/routeMatching.ts index 54a4c5265c8..fe8d20a4c3e 100644 --- a/packages/remix-server-runtime/routeMatching.ts +++ b/packages/remix-server-runtime/routeMatching.ts @@ -11,11 +11,13 @@ export interface RouteMatch { export function matchServerRoutes( routes: ServerRoute[], - pathname: string + pathname: string, + basename?: string ): RouteMatch[] | null { let matches = matchRoutes( routes as unknown as AgnosticRouteObject[], - pathname + pathname, + basename ); if (!matches) return null; diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 5d2269d9dbe..e92933f74b2 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -9,6 +9,7 @@ import { isRouteErrorResponse, createStaticHandler, json as routerJson, + stripBasename, UNSAFE_ErrorResponseImpl as ErrorResponseImpl, } from "@remix-run/router"; @@ -46,6 +47,7 @@ function derive(build: ServerBuild, mode?: string) { let dataRoutes = createStaticHandlerDataRoutes(build.routes, build.future); let serverMode = isServerMode(mode) ? mode : ServerMode.Production; let staticHandler = createStaticHandler(dataRoutes, { + basename: build.basename, future: { v7_relativeSplatPath: build.future?.v3_relativeSplatPath === true, v7_throwAbortReason: build.future?.v3_throwAbortReason === true, @@ -100,7 +102,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( let url = new URL(request.url); - let matches = matchServerRoutes(routes, url.pathname); + let matches = matchServerRoutes(routes, url.pathname, _build.basename); let handleError = (error: unknown) => { if (mode === ServerMode.Development) { getDevServerHooks()?.processRequestError?.(error); @@ -119,6 +121,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( response = await handleDataRequestRR( serverMode, + _build, staticHandler, routeId, request, @@ -177,6 +180,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( async function handleDataRequestRR( serverMode: ServerMode, + build: ServerBuild, staticHandler: StaticHandler, routeId: string, request: Request, @@ -194,7 +198,13 @@ async function handleDataRequestRR( // redirects. So we use the `X-Remix-Redirect` header to indicate the // next URL, and then "follow" the redirect manually on the client. let headers = new Headers(response.headers); - headers.set("X-Remix-Redirect", headers.get("Location")!); + let redirectUrl = headers.get("Location")!; + headers.set( + "X-Remix-Redirect", + build.basename + ? stripBasename(redirectUrl, build.basename) || redirectUrl + : redirectUrl + ); headers.set("X-Remix-Status", response.status); headers.delete("Location"); if (response.headers.get("Set-Cookie") !== null) { @@ -298,6 +308,7 @@ async function handleDocumentRequestRR( criticalCss, serverHandoffString: createServerHandoffString({ url: context.location.pathname, + basename: build.basename, criticalCss, state: { loaderData: context.loaderData, @@ -372,6 +383,7 @@ async function handleDocumentRequestRR( staticHandlerContext: context, serverHandoffString: createServerHandoffString({ url: context.location.pathname, + basename: build.basename, state: { loaderData: context.loaderData, actionData: context.actionData, diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts index 6d5dd252b3b..e155e727a9b 100644 --- a/packages/remix-server-runtime/serverHandoff.ts +++ b/packages/remix-server-runtime/serverHandoff.ts @@ -21,6 +21,7 @@ export function createServerHandoffString(serverHandoff: { state: ValidateShape; criticalCss?: string; url: string; + basename: string | undefined; future: FutureConfig; isSpaMode: boolean; }): string {