diff --git a/integration/helpers/vite-template/tsconfig.json b/integration/helpers/vite-template/tsconfig.json index ad5ae05598e..0ba49af4c8e 100644 --- a/integration/helpers/vite-template/tsconfig.json +++ b/integration/helpers/vite-template/tsconfig.json @@ -14,7 +14,9 @@ "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { - "~/*": ["./app/*"] + "~/*": ["./app/*"], + "#dir/*": ["./app/.server/*"], + "#file": ["./app/utils.server"] }, "noEmit": true } diff --git a/integration/package.json b/integration/package.json index 99f6ff48453..cd226fc5470 100644 --- a/integration/package.json +++ b/integration/package.json @@ -35,6 +35,6 @@ "tailwindcss": "^3.3.0", "type-fest": "^4.0.0", "typescript": "^5.1.0", - "vite-tsconfig-paths": "^4.2.1" + "vite-tsconfig-paths": "^4.2.2" } } diff --git a/integration/vite-dot-server-test.ts b/integration/vite-dot-server-test.ts index ae53192e3f8..f007c9b108a 100644 --- a/integration/vite-dot-server-test.ts +++ b/integration/vite-dot-server-test.ts @@ -1,144 +1,62 @@ import * as path from "node:path"; import { test, expect } from "@playwright/test"; +import stripAnsi from "strip-ansi"; import { createProject, grep, viteBuild } from "./helpers/vite.js"; -let files = { - "app/utils.server.ts": String.raw` - export const dotServerFile = "SERVER_ONLY_FILE"; - export default dotServerFile; - `, - "app/.server/utils.ts": String.raw` - export const dotServerDir = "SERVER_ONLY_DIR"; - export default dotServerDir; - `, -}; - -test("Vite / .server file / named import in client fails with expected error", async () => { - let cwd = await createProject({ - ...files, - "app/routes/fail-server-file-in-client.tsx": String.raw` - import { dotServerFile } from "~/utils.server"; +let serverOnlyModule = String.raw` + export const serverOnly = "SERVER_ONLY"; + export default serverOnly; +`; + +let tsconfig = (aliases: Record) => String.raw` + { + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": ${JSON.stringify(aliases)}, + "noEmit": true + } + } +`; - export default function() { - console.log(dotServerFile); - return

Fail: Server file included in client

- } - `, - }); - let result = viteBuild({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch( - `"dotServerFile" is not exported by "app/utils.server.ts"` - ); -}); - -test("Vite / .server file / namespace import in client fails with expected error", async () => { - let cwd = await createProject({ - ...files, - "app/routes/fail-server-file-in-client.tsx": String.raw` - import * as utils from "~/utils.server"; - - export default function() { - console.log(utils.dotServerFile); - return

Fail: Server file included in client

- } - `, - }); - let result = viteBuild({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch( - `"dotServerFile" is not exported by "app/utils.server.ts"` - ); -}); - -test("Vite / .server file / default import in client fails with expected error", async () => { - let cwd = await createProject({ - ...files, - "app/routes/fail-server-file-in-client.tsx": String.raw` - import dotServerFile from "~/utils.server"; - - export default function() { - console.log(dotServerFile); - return

Fail: Server file included in client

- } - `, - }); - let result = viteBuild({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch(`"default" is not exported by "app/utils.server.ts"`); -}); - -test("Vite / .server dir / named import in client fails with expected error", async () => { - let cwd = await createProject({ - ...files, - "app/routes/fail-server-dir-in-client.tsx": String.raw` - import { dotServerDir } from "~/.server/utils"; - - export default function() { - console.log(dotServerDir); - return

Fail: Server directory included in client

- } - `, - }); - let result = viteBuild({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch( - `"dotServerDir" is not exported by "app/.server/utils.ts"` - ); -}); - -test("Vite / .server dir / namespace import in client fails with expected error", async () => { +test("Vite / dead-code elimination for server exports", async () => { let cwd = await createProject({ - ...files, - "app/routes/fail-server-dir-in-client.tsx": String.raw` - import * as utils from "~/.server/utils"; - - export default function() { - console.log(utils.dotServerDir); - return

Fail: Server directory included in client

- } - `, - }); - let result = viteBuild({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch( - `"dotServerDir" is not exported by "app/.server/utils.ts"` - ); -}); + "app/utils.server.ts": serverOnlyModule, + "app/.server/utils.ts": serverOnlyModule, + "app/routes/remove-server-exports-and-dce.tsx": String.raw` + import fs from "node:fs"; + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; -test("Vite / .server dir / default import in client fails with expected error", async () => { - let cwd = await createProject({ - ...files, - "app/routes/fail-server-dir-in-client.tsx": String.raw` - import dotServerDir from "~/.server/utils"; + import { serverOnly as serverOnlyFile } from "../utils.server"; + import { serverOnly as serverOnlyDir } from "../.server/utils"; - export default function() { - console.log(dotServerDir); - return

Fail: Server directory included in client

+ export const loader = () => { + let contents = fs.readFileSync("server_only.txt"); + return json({ serverOnlyFile, serverOnlyDir, contents }) } - `, - }); - let result = viteBuild({ cwd }); - let stderr = result.stderr.toString("utf8"); - expect(stderr).toMatch(`"default" is not exported by "app/.server/utils.ts"`); -}); -test("Vite / `handle` with dynamic imports as an escape hatch for server-only code", async () => { - let cwd = await createProject({ - ...files, - "app/routes/handle-server-only.tsx": String.raw` - export const handle = { - // Sharp knife alert: you probably should avoid doing this, but you can! - serverOnlyEscapeHatch: async () => { - let { dotServerFile } = await import("~/utils.server"); - let dotServerDir = await import("~/.server/utils"); - return { dotServerFile, dotServerDir }; - } + export const action = () => { + let contents = fs.readFileSync("server_only.txt"); + console.log({ serverOnlyFile, serverOnlyDir, contents }); + return null; } export default function() { - return

This should work

+ let { data } = useLoaderData(); + return
{JSON.stringify(data)}
; } `, }); @@ -147,50 +65,178 @@ test("Vite / `handle` with dynamic imports as an escape hatch for server-only co let lines = grep( path.join(cwd, "build/client"), - /SERVER_ONLY_FILE|SERVER_ONLY_DIR/ + /SERVER_ONLY|SERVER_ONLY|node:fs/ ); expect(lines).toHaveLength(0); }); -test("Vite / dead-code elimination for server exports", async () => { - let cwd = await createProject({ - ...files, - "app/routes/remove-server-exports-and-dce.tsx": String.raw` - import fs from "node:fs"; - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - import { dotServerFile } from "../utils.server"; - import { dotServerDir } from "../.server/utils"; +test.describe("Vite / route / server-only module referenced by client", () => { + let matrix = [ + { type: "file", path: "app/utils.server.ts", specifier: `~/utils.server` }, + { type: "dir", path: "app/.server/utils.ts", specifier: `~/.server/utils` }, + + // aliases + { + type: "file alias", + path: "app/utils.server.ts", + specifier: `#dot-server-file`, + }, + { + type: "dir alias", + path: "app/.server/utils.ts", + specifier: `#dot-server-dir/utils`, + }, + ]; + + let cases = matrix.flatMap(({ type, path, specifier }) => [ + { + name: `default import / .server ${type}`, + path, + specifier, + route: ` + import serverOnly from "${specifier}"; + export default () =>

{serverOnly}

; + `, + }, + { + name: `named import / .server ${type}`, + path, + specifier, + route: ` + import { serverOnly } from "${specifier}" + export default () =>

{serverOnly}

; + `, + }, + { + name: `namespace import / .server ${type}`, + path, + specifier, + route: ` + import * as utils from "${specifier}" + export default () =>

{utils.serverOnly}

; + `, + }, + ]); + + for (let { name, path, specifier, route } of cases) { + test(name, async () => { + let cwd = await createProject({ + "tsconfig.json": tsconfig({ + "~/*": ["app/*"], + "#dot-server-file": ["app/utils.server.ts"], + "#dot-server-dir/*": ["app/.server/*"], + }), + [path]: serverOnlyModule, + "app/routes/_index.tsx": route, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + [ + "Server-only module referenced by client", + + ` '${specifier}' imported by route 'app/routes/_index.tsx'`, + + " The only route exports that can reference server-only modules are: `loader`, `action`, `headers`", + ` but other route exports in 'app/routes/_index.tsx' depend on '${specifier}'.`, + + " For more see https://remix.run/docs/en/main/discussion/server-vs-client", + ].forEach(expect(stderr).toMatch); + }); + } +}); - export const loader = () => { - let contents = fs.readFileSync("blah"); - let data = dotServerFile + dotServerDir + serverOnly + contents; - return json({ data }); - } +test.describe("Vite / non-route / server-only module referenced by client", () => { + let matrix = [ + { type: "file", path: "app/utils.server.ts", specifier: `~/utils.server` }, + { type: "dir", path: "app/.server/utils.ts", specifier: `~/.server/utils` }, + ]; + + let cases = matrix.flatMap(({ type, path, specifier }) => [ + { + name: `default import / .server ${type}`, + path, + specifier, + nonroute: ` + import serverOnly from "${specifier}"; + export const getServerOnly = () => serverOnly; + `, + }, + { + name: `named import / .server ${type}`, + path, + specifier, + nonroute: ` + import { serverOnly } from "${specifier}"; + export const getServerOnly = () => serverOnly; + `, + }, + { + name: `namespace import / .server ${type}`, + path, + specifier, + nonroute: ` + import * as utils from "${specifier}"; + export const getServerOnly = () => utils.serverOnly; + `, + }, + ]); + + for (let { name, path, specifier, nonroute } of cases) { + test(name, async () => { + let cwd = await createProject({ + [path]: serverOnlyModule, + "app/reexport-server-only.ts": nonroute, + "app/routes/_index.tsx": String.raw` + import { serverOnly } from "~/reexport-server-only" + export default () =>

{serverOnly}

; + `, + }); + let result = viteBuild({ cwd }); + let stderr = stripAnsi(result.stderr.toString("utf8")); + + [ + `Server-only module referenced by client`, + + ` '${specifier}' imported by 'app/reexport-server-only.ts'`, + + " * If all code in 'app/reexport-server-only.ts' is server-only:", + + " Rename it to 'app/reexport-server-only.server.ts'", + + " * Otherwise:", + + " Keep client-safe code in 'app/reexport-server-only.ts'", + " and move server-only code:", + + " - Into a `.server` directory e.g. 'app/.server/utils.ts'", + " - Or into a `.server` file e.g. 'app/reexport-server-only.server.ts'", + + "For more, see https://remix.run/docs/en/main/future/vite#server-code-not-tree-shaken-in-development", + ].forEach(expect(stderr).toMatch); + }); + } +}); - export const action = () => { - console.log(dotServerFile, dotServerDir, serverOnly); - return null; +test("Vite / `handle` with dynamic imports as an escape hatch for server-only code", async () => { + let cwd = await createProject({ + "app/utils.server.ts": serverOnlyModule, + "app/.server/utils.ts": serverOnlyModule, + "app/routes/handle-server-only.tsx": String.raw` + export const handle = { + escapeHatch: !import.meta.env.SSR ? undefined : + async () => { + let { serverOnly: serverOnlyFile } = await import("~/utils.server"); + let serverOnlyDir = await import("~/.server/utils"); + return { serverOnlyFile, serverOnlyDir }; + } } - export default function() { - let { data } = useLoaderData(); - return ( - <> -

Index

-

{data}

- - ); - } + export default () =>

This should work

; `, }); let { status } = viteBuild({ cwd }); expect(status).toBe(0); - let lines = grep( - path.join(cwd, "build/client"), - /SERVER_ONLY_FILE|SERVER_ONLY_DIR|node:fs/ - ); + let lines = grep(path.join(cwd, "build/client"), /SERVER_ONLY/); expect(lines).toHaveLength(0); }); diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index df8d1e5758b..3b99c6cfb91 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -61,6 +61,8 @@ const ROUTE_EXPORTS = new Set([ "shouldRevalidate", ]); +const SERVER_ONLY_EXPORTS = ["loader", "action", "headers"]; + // We need to provide different JSDoc comments in some cases due to differences // between the Remix config and the Vite plugin. type RemixConfigJsdocOverrides = { @@ -947,22 +949,88 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }, }, { - name: "remix-empty-server-modules", + name: "remix-dot-server", enforce: "pre", - async transform(_code, id, options) { + async resolveId(id, importer, options) { if (options?.ssr) return; + + let isResolving = options?.custom?.["remix-dot-server"] ?? false; + if (isResolving) return; + options.custom = { ...options.custom, "remix-dot-server": true }; + let resolved = await this.resolve(id, importer, options); + if (!resolved) return; + let serverFileRE = /\.server(\.[cm]?[jt]sx?)?$/; let serverDirRE = /\/\.server\//; - if (serverFileRE.test(id) || serverDirRE.test(id)) { - return { - code: "export {}", - map: null, - }; + let isDotServer = + serverFileRE.test(resolved!.id) || serverDirRE.test(resolved!.id); + if (!isDotServer) return; + + if (!importer) throw Error(`Importer not found: ${id}`); + + let pluginConfig = await resolvePluginConfig(); + let importerShort = path.relative(pluginConfig.rootDirectory, importer); + let isRoute = getRoute(pluginConfig, importer); + + if (isRoute) { + let serverOnlyExports = SERVER_ONLY_EXPORTS.map( + (xport) => `\`${xport}\`` + ).join(", "); + throw Error( + [ + colors.red(`Server-only module referenced by client`), + "", + ` '${id}' imported by route '${importerShort}'`, + "", + ` The only route exports that can reference server-only modules are: ${serverOnlyExports}`, + ` but other route exports in '${importerShort}' depend on '${id}'.`, + "", + " For more see https://remix.run/docs/en/main/discussion/server-vs-client", + "", + ].join("\n") + ); } + + let importedBy = path.parse(importerShort); + let ext = importedBy.ext === ".jsx" ? ".js" : ".ts"; + let dotServerDir = path + .join( + path.basename(pluginConfig.appDirectory), + ".server", + "utils" + ext + ) + .replace(/\.jsx$/, ".js"); + let dotServerFile = path.join( + importedBy.dir, + importedBy.name + ".server" + ext + ); + + throw Error( + [ + colors.red(`Server-only module referenced by client`), + "", + ` '${id}' imported by '${importerShort}'`, + "", + ` * If all code in '${importerShort}' is server-only:`, + "", + ` Rename it to '${dotServerFile}'`, + "", + ` * Otherwise:`, + "", + ` Keep client-safe code in '${importerShort}'`, + ` and move server-only code:`, + "", + ` - Into a \`.server\` directory e.g. '${dotServerDir}'`, + ` - Or into a \`.server\` file e.g. '${dotServerFile}'`, + "", + " For more, see https://remix.run/docs/en/main/future/vite#server-code-not-tree-shaken-in-development", + "", + ].join("\n") + ); }, }, { - name: "remix-empty-client-modules", + name: "remix-dot-client", enforce: "post", async transform(code, id, options) { if (!options?.ssr) return; @@ -1011,10 +1079,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { throw Error(message); } - let serverExports = ["loader", "action", "headers"]; - return { - code: removeExports(code, serverExports), + code: removeExports(code, SERVER_ONLY_EXPORTS), map: null, }; }, diff --git a/yarn.lock b/yarn.lock index 065bfc47898..0366f307f6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13093,10 +13093,10 @@ vite-node@^0.28.5: source-map-support "^0.5.21" vite "^3.0.0 || ^4.0.0" -vite-tsconfig-paths@^4.2.1: - version "4.2.1" - resolved "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.2.1.tgz#e53b89096b91d31a6d1e26f75999ea8c336a89ed" - integrity sha512-GNUI6ZgPqT3oervkvzU+qtys83+75N/OuDaQl7HmOqFTb0pjZsuARrRipsyJhJ3enqV8beI1xhGbToR4o78nSQ== +vite-tsconfig-paths@^4.2.2: + version "4.2.2" + resolved "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.2.2.tgz#fee5a59c885687ae046e1d5a394bdcfdb12d9361" + integrity sha512-dq0FjyxHHDnp0uS3P12WEOX2W7NeuLzX9AWP38D7Zw2CTbFErapwQVlCiT5DMJcVWKQ1MMdTe92PZl/rBQ7qcw== dependencies: debug "^4.1.1" globrex "^0.1.2"