From 86ce9ec0c7e8b380f616bb78c3ac178f879ca216 Mon Sep 17 00:00:00 2001 From: Johannes Waigel Date: Wed, 31 Jul 2024 02:31:47 +0200 Subject: [PATCH 1/6] fix: Ensure Vite respects custom outDir in build configuration --- packages/react-server-next/src/vite/index.ts | 3 +- .../examples/custom-out-dir/.gitignore | 1 + .../examples/custom-out-dir/app/page.tsx | 11 + .../custom-out-dir/e2e/out-dir.test.ts | 16 ++ .../examples/custom-out-dir/package.json | 26 ++ .../custom-out-dir/playwright.config.ts | 32 +++ .../examples/custom-out-dir/tsconfig.json | 17 ++ .../examples/custom-out-dir/vite.config.ts | 10 + packages/react-server/src/entry/ssr.tsx | 17 +- .../src/features/assets/plugin.ts | 10 +- .../react-server/src/features/next/plugin.ts | 7 +- .../src/features/prerender/plugin.ts | 21 +- .../src/features/router/plugin.ts | 14 +- packages/react-server/src/plugin/index.ts | 234 ++++++++++-------- .../vite-plugin-ssr-middleware/src/plugin.ts | 9 + pnpm-lock.yaml | 31 +++ 16 files changed, 325 insertions(+), 134 deletions(-) create mode 100644 packages/react-server/examples/custom-out-dir/.gitignore create mode 100644 packages/react-server/examples/custom-out-dir/app/page.tsx create mode 100644 packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts create mode 100644 packages/react-server/examples/custom-out-dir/package.json create mode 100644 packages/react-server/examples/custom-out-dir/playwright.config.ts create mode 100644 packages/react-server/examples/custom-out-dir/tsconfig.json create mode 100644 packages/react-server/examples/custom-out-dir/vite.config.ts diff --git a/packages/react-server-next/src/vite/index.ts b/packages/react-server-next/src/vite/index.ts index f4d9f0c52..c10081211 100644 --- a/packages/react-server-next/src/vite/index.ts +++ b/packages/react-server-next/src/vite/index.ts @@ -1,5 +1,4 @@ import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; import { type ReactServerPluginOptions, vitePluginReactServer, @@ -32,7 +31,7 @@ export default function vitePluginReactServerNext( vitePluginLogger(), vitePluginSsrMiddleware({ entry: "next/vite/entry-ssr", - preview: path.resolve("./dist/server/index.js"), + preview: "server/index.js", }), adapterPlugin({ adapter: options?.adapter }), appFaviconPlugin(), diff --git a/packages/react-server/examples/custom-out-dir/.gitignore b/packages/react-server/examples/custom-out-dir/.gitignore new file mode 100644 index 000000000..7c0a2a98b --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/.gitignore @@ -0,0 +1 @@ +custom-out-dir/ \ No newline at end of file diff --git a/packages/react-server/examples/custom-out-dir/app/page.tsx b/packages/react-server/examples/custom-out-dir/app/page.tsx new file mode 100644 index 000000000..76b16e28b --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/app/page.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +function page() { + return ( +
+

Hello from custom out dir!

+
+ ); +} + +export default page; diff --git a/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts b/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts new file mode 100644 index 000000000..2599220cb --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts @@ -0,0 +1,16 @@ +import fs from "fs"; +import { expect, test } from "@playwright/test"; +test("app renders", async ({ page }) => { + const res = await page.goto("/"); + expect(res?.status()).toBe(200); + expect(await page.textContent("h1")).toBe("Hello from custom out dir!"); +}); + +test("custom out directory should exist", () => { + const outDir = "custom-out-dir"; + expect(fs.existsSync(outDir)).toBe(true); +}); + +test("default out directory should not exist", () => { + expect(fs.existsSync("dist")).toBe(false); +}); diff --git a/packages/react-server/examples/custom-out-dir/package.json b/packages/react-server/examples/custom-out-dir/package.json new file mode 100644 index 000000000..bc88e0343 --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hiogawa/react-server-example-custom-out-dir", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test-e2e": "playwright test", + "test-e2e-preview": "E2E_PREVIEW=1 playwright test" + }, + "dependencies": { + "@hiogawa/react-server": "workspace:*", + "next": "link:../../../react-server-next", + "react": "rc", + "react-dom": "rc", + "react-server-dom-webpack": "rc" + }, + "devDependencies": { + "@playwright/test": "^1.45.1", + "@types/react": "latest", + "@types/react-dom": "latest", + "vite": "latest" + } +} diff --git a/packages/react-server/examples/custom-out-dir/playwright.config.ts b/packages/react-server/examples/custom-out-dir/playwright.config.ts new file mode 100644 index 000000000..6056ad83b --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.E2E_PORT || 6174); +const isPreview = Boolean(process.env.E2E_PREVIEW); +const command = isPreview + ? `pnpm start --port ${port} --strict-port` + : `pnpm dev --port ${port} --strict-port`; + +export default defineConfig({ + testDir: "e2e", + use: { + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: null, + deviceScaleFactor: undefined, + }, + }, + ], + webServer: { + command, + port, + }, + grepInvert: isPreview ? /@dev/ : /@build/, + forbidOnly: !!process.env["CI"], + retries: process.env["CI"] ? 2 : 0, + reporter: "list", +}); diff --git a/packages/react-server/examples/custom-out-dir/tsconfig.json b/packages/react-server/examples/custom-out-dir/tsconfig.json new file mode 100644 index 000000000..c66ba45e0 --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/tsconfig.json @@ -0,0 +1,17 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["next"], + "jsx": "react-jsx" + } +} diff --git a/packages/react-server/examples/custom-out-dir/vite.config.ts b/packages/react-server/examples/custom-out-dir/vite.config.ts new file mode 100644 index 000000000..b26804d10 --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/vite.config.ts @@ -0,0 +1,10 @@ +import next from "next/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [next()], + build: { + outDir: "custom-out-dir", + emptyOutDir: true, + }, +}); diff --git a/packages/react-server/src/entry/ssr.tsx b/packages/react-server/src/entry/ssr.tsx index 12d974285..d9cc03605 100644 --- a/packages/react-server/src/entry/ssr.tsx +++ b/packages/react-server/src/entry/ssr.tsx @@ -41,7 +41,10 @@ import type { ReactServerHandlerStreamResult } from "./server"; const debug = createDebug("react-server:ssr"); -export async function handler(request: Request): Promise { +export async function handler( + request: Request, + outDir: string, +): Promise { // dev only api endpoint to test internal if ( import.meta.env.DEV && @@ -50,7 +53,7 @@ export async function handler(request: Request): Promise { return devInspectHandler(request); } - const reactServer = await importReactServer(); + const reactServer = await importReactServer(outDir); // server action and render rsc stream const result = await reactServer.handler({ request }); @@ -63,8 +66,8 @@ export async function handler(request: Request): Promise { } // return stream and ssr at once for prerender -export async function prerender(request: Request) { - const reactServer = await importReactServer(); +export async function prerender(request: Request, outDir: string) { + const reactServer = await importReactServer(outDir); const result = await reactServer.handler({ request }); tinyassert(!(result instanceof Response)); @@ -77,11 +80,13 @@ export async function prerender(request: Request) { return { stream, response, html }; } -export async function importReactServer(): Promise { +export async function importReactServer( + outDir: string, +): Promise { if (import.meta.env.DEV) { return $__global.dev.reactServer.ssrLoadModule(ENTRY_SERVER_WRAPPER) as any; } else { - return import("/dist/rsc/index.js" as string); + return import(`./${outDir}/rsc/index.js` as string); } } diff --git a/packages/react-server/src/features/assets/plugin.ts b/packages/react-server/src/features/assets/plugin.ts index 300c28641..6cf5f55cc 100644 --- a/packages/react-server/src/features/assets/plugin.ts +++ b/packages/react-server/src/features/assets/plugin.ts @@ -65,7 +65,7 @@ export function vitePluginServerAssets({ // TODO: (refactor) use RouteManifest? const manifest: Manifest = JSON.parse( await fs.promises.readFile( - "dist/client/.vite/manifest.json", + path.join(manager.outDir, "client", ".vite", "manifest.json"), "utf-8", ), ); @@ -138,8 +138,8 @@ export function vitePluginServerAssets({ if (manager.buildType === "browser") { for (const file of manager.serverAssets) { await fs.promises.cp( - path.join("dist/rsc", file), - path.join("dist/client", file), + path.join(manager.outDir, "rsc", file), + path.join(manager.outDir, "client", file), ); } } @@ -150,7 +150,9 @@ export function vitePluginServerAssets({ export function serverAssertsPluginServer({ manager, -}: { manager: PluginStateManager }): Plugin[] { +}: { + manager: PluginStateManager; +}): Plugin[] { // 0. track server assets during server build (this plugin) // 1. copy all server assets to browser build (copy-build plugin) // 2. out of those, inject links automatically (ssr-assets virtual plugin) diff --git a/packages/react-server/src/features/next/plugin.ts b/packages/react-server/src/features/next/plugin.ts index b2e8e08ba..b67fb9309 100644 --- a/packages/react-server/src/features/next/plugin.ts +++ b/packages/react-server/src/features/next/plugin.ts @@ -1,4 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import type { Rollup } from "vite"; // ensure `.js` extension even if project root is cjs @@ -8,7 +9,7 @@ export const OUTPUT_SERVER_JS_EXT = { chunkFileNames: "assets/[name]-[hash].js", } satisfies Rollup.OutputOptions; -export async function createServerPackageJson() { - await mkdir("dist", { recursive: true }); - await writeFile("dist/package.json", `{ "type": "module" }`); +export async function createServerPackageJson(outDir: string) { + await mkdir(outDir, { recursive: true }); + await writeFile(path.join(outDir, "package.json"), `{ "type": "module" }`); } diff --git a/packages/react-server/src/features/prerender/plugin.ts b/packages/react-server/src/features/prerender/plugin.ts index 3b275d2d2..474e2f0e7 100644 --- a/packages/react-server/src/features/prerender/plugin.ts +++ b/packages/react-server/src/features/prerender/plugin.ts @@ -39,7 +39,7 @@ export function prerenderPlugin({ writeBundle: { sequential: true, handler() { - return processPrerender(prerender); + return processPrerender(prerender, manager.outDir); }, }, }, @@ -66,12 +66,15 @@ function urlPathToHtmlPath(pathname: string) { return pathname + (pathname.endsWith("/") ? "index.html" : ".html"); } -async function processPrerender(getPrerenderRoutes: PrerenderFn) { +async function processPrerender( + getPrerenderRoutes: PrerenderFn, + outDir: string, +) { console.log("▶▶▶ PRERENDER"); const entry: typeof import("../../entry/ssr") = await import( - path.resolve("dist/server/__entry_ssr.js") + path.resolve(path.join(outDir, "server", "__entry_ssr.js")) ); - const { router } = await entry.importReactServer(); + const { router } = await entry.importReactServer(outDir); const presets = createPrerenderPresets(router.manifest); const routes = await getPrerenderRoutes(router.manifest, presets); const manifest: PrerenderManifest = { entries: [] }; @@ -83,15 +86,15 @@ async function processPrerender(getPrerenderRoutes: PrerenderFn) { "x-react-server-render-mode": "prerender", }, }); - const { stream, html } = await entry.prerender(request); + const { stream, html } = await entry.prerender(request, outDir); const data = Readable.from(stream as any); const htmlFile = urlPathToHtmlPath(route); const dataFile = route + RSC_PATH; - await mkdir(path.dirname(path.join("dist/client", htmlFile)), { + await mkdir(path.dirname(path.join(outDir, "client", htmlFile)), { recursive: true, }); - await writeFile(path.join("dist/client", htmlFile), html); - await writeFile(path.join("dist/client", dataFile), data); + await writeFile(path.join(outDir, "client", htmlFile), html); + await writeFile(path.join(outDir, "client", dataFile), data); manifest.entries.push({ route, html: htmlFile, @@ -99,7 +102,7 @@ async function processPrerender(getPrerenderRoutes: PrerenderFn) { }); } await writeFile( - "dist/client/__prerender.json", + path.join(outDir, "client", "__prerender.json"), JSON.stringify(manifest, null, 2), ); } diff --git a/packages/react-server/src/features/router/plugin.ts b/packages/react-server/src/features/router/plugin.ts index 204a4b2d5..c058e7e60 100644 --- a/packages/react-server/src/features/router/plugin.ts +++ b/packages/react-server/src/features/router/plugin.ts @@ -24,7 +24,10 @@ import { createFsRouteTree } from "./tree"; export function routeManifestPluginServer({ manager, routeDir, -}: { manager: PluginStateManager; routeDir: string }): Plugin[] { +}: { + manager: PluginStateManager; + routeDir: string; +}): Plugin[] { return [ { name: "server-route-manifest", @@ -60,7 +63,9 @@ export function routeManifestPluginServer({ export function routeManifestPluginClient({ manager, -}: { manager: PluginStateManager }): Plugin[] { +}: { + manager: PluginStateManager; +}): Plugin[] { return [ { name: routeManifestPluginClient.name + ":bundle", @@ -96,7 +101,10 @@ export function routeManifestPluginClient({ const source = `${JSON.stringify(data, null, 2)}`; const sourceHash = hashString(source).slice(0, 8); const url = `/assets/route-manifest-${sourceHash}.js`; - writeFileSync(`dist/client${url}`, `export default ${source}`); + writeFileSync( + path.join(manager.outDir, `client${url}`), + `export default ${source}`, + ); // give asset url and manifest to ssr return `export default ${JSON.stringify( diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index 48c88babf..709161ae8 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -75,6 +75,8 @@ class PluginStateManager { config!: ResolvedConfig; configEnv!: ConfigEnv; + outDir!: string; + buildType?: "scan" | "server" | "browser" | "ssr"; routeToClientReferences: Record = {}; @@ -135,127 +137,136 @@ export function vitePluginReactServer( options?.entryServer ?? "@hiogawa/react-server/entry/server"; const routeDir = options?.routeDir ?? "src/routes"; - const reactServerViteConfig: InlineConfig = { - customLogger: createLogger(undefined, { - prefix: "[react-server]", - allowClearScreen: false, - }), - clearScreen: false, - configFile: false, - cacheDir: "./node_modules/.vite-rsc", - optimizeDeps: { - noDiscovery: true, - include: [], - }, - plugins: [ - ...(options?.plugins ?? []), - - vitePluginSilenceDirectiveBuildWarning(), - - // expose server reference to react-server itself - vitePluginServerUseServer({ - manager, - runtimePath: RUNTIME_SERVER_PATH, - }), - - // transform "use client" into client referecnes - vitePluginServerUseClient({ - manager, - runtimePath: RUNTIME_SERVER_PATH, - }), - - routeManifestPluginServer({ manager, routeDir }), - - createVirtualPlugin("server-routes", () => { - return ` - const glob = import.meta.glob( - "/${routeDir}/**/(page|layout|error|not-found|loading|template|route).(js|jsx|ts|tsx)", - { eager: true }, - ); - export default Object.fromEntries( - Object.entries(glob).map( - ([k, v]) => [k.slice("/${routeDir}".length), v] - ) - ); - - const globMiddleware = import.meta.glob("/middleware.(js|jsx|ts|tsx)", { eager: true }); - export const middleware = Object.values(globMiddleware)[0]; - `; + const createReactServerViteConfig = (outDir: string): InlineConfig => { + return { + customLogger: createLogger(undefined, { + prefix: "[react-server]", + allowClearScreen: false, }), + clearScreen: false, + configFile: false, + cacheDir: "./node_modules/.vite-rsc", + optimizeDeps: { + noDiscovery: true, + include: [], + }, + plugins: [ + ...(options?.plugins ?? []), - createVirtualPlugin( - ENTRY_SERVER_WRAPPER.slice("virtual:".length), - () => ` - import "virtual:inject-async-local-storage"; - export { handler } from "${entryServer}"; - export { router } from "@hiogawa/react-server/entry/server"; - `, - ), - - // make `AsyncLocalStorage` available globally for React.cache from edge build - // https://github.com/facebook/react/blob/f14d7f0d2597ea25da12bcf97772e8803f2a394c/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js#L16-L19 - createVirtualPlugin("inject-async-local-storage", () => { - if (options?.noAsyncLocalStorage) { - return "export {}"; - } - return ` - import { AsyncLocalStorage } from "node:async_hooks"; - Object.assign(globalThis, { AsyncLocalStorage }); - `; - }), + vitePluginSilenceDirectiveBuildWarning(), - validateImportPlugin({ - "client-only": `'client-only' is included in server build`, - "server-only": true, - }), + // expose server reference to react-server itself + vitePluginServerUseServer({ + manager, + runtimePath: RUNTIME_SERVER_PATH, + }), - serverAssertsPluginServer({ manager }), + // transform "use client" into client referecnes + vitePluginServerUseClient({ + manager, + runtimePath: RUNTIME_SERVER_PATH, + }), - serverDepsConfigPlugin(), + routeManifestPluginServer({ manager, routeDir }), - { - name: "patch-react-server-dom-webpack", - transform(code, id, _options) { - if (id.includes("react-server-dom-webpack")) { - // rename webpack markers in react server runtime - // to avoid conflict with ssr runtime which shares same globals - code = code.replaceAll( - "__webpack_require__", - "__vite_react_server_webpack_require__", + createVirtualPlugin("server-routes", () => { + return ` + const glob = import.meta.glob( + "/${routeDir}/**/(page|layout|error|not-found|loading|template|route).(js|jsx|ts|tsx)", + { eager: true }, ); - code = code.replaceAll( - "__webpack_chunk_load__", - "__vite_react_server_webpack_chunk_load__", + export default Object.fromEntries( + Object.entries(glob).map( + ([k, v]) => [k.slice("/${routeDir}".length), v] + ) ); - - // make server reference async for simplicity (stale chunkCache, etc...) - // see TODO in https://github.com/facebook/react/blob/33a32441e991e126e5e874f831bd3afc237a3ecf/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js#L131-L132 - code = code.replaceAll("if (isAsyncImport(metadata))", "if (true)"); - code = code.replaceAll("4 === metadata.length", "true"); - - return code; + + const globMiddleware = import.meta.glob("/middleware.(js|jsx|ts|tsx)", { eager: true }); + export const middleware = Object.values(globMiddleware)[0]; + `; + }), + + createVirtualPlugin( + ENTRY_SERVER_WRAPPER.slice("virtual:".length), + () => ` + import "virtual:inject-async-local-storage"; + export { handler } from "${entryServer}"; + export { router } from "@hiogawa/react-server/entry/server"; + `, + ), + + // make `AsyncLocalStorage` available globally for React.cache from edge build + // https://github.com/facebook/react/blob/f14d7f0d2597ea25da12bcf97772e8803f2a394c/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js#L16-L19 + createVirtualPlugin("inject-async-local-storage", () => { + if (options?.noAsyncLocalStorage) { + return "export {}"; } - return; + return ` + import { AsyncLocalStorage } from "node:async_hooks"; + Object.assign(globalThis, { AsyncLocalStorage }); + `; + }), + + validateImportPlugin({ + "client-only": `'client-only' is included in server build`, + "server-only": true, + }), + + serverAssertsPluginServer({ manager }), + + serverDepsConfigPlugin(), + + { + name: "patch-react-server-dom-webpack", + transform(code, id, _options) { + if (id.includes("react-server-dom-webpack")) { + // rename webpack markers in react server runtime + // to avoid conflict with ssr runtime which shares same globals + code = code.replaceAll( + "__webpack_require__", + "__vite_react_server_webpack_require__", + ); + code = code.replaceAll( + "__webpack_chunk_load__", + "__vite_react_server_webpack_chunk_load__", + ); + + // make server reference async for simplicity (stale chunkCache, etc...) + // see TODO in https://github.com/facebook/react/blob/33a32441e991e126e5e874f831bd3afc237a3ecf/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js#L131-L132 + code = code.replaceAll( + "if (isAsyncImport(metadata))", + "if (true)", + ); + code = code.replaceAll("4 === metadata.length", "true"); + + return code; + } + return; + }, }, - }, - ], - build: { - ssr: true, - manifest: true, - ssrEmitAssets: true, - outDir: "dist/rsc", - rollupOptions: { - input: { - index: ENTRY_SERVER_WRAPPER, + ], + build: { + ssr: true, + manifest: true, + ssrEmitAssets: true, + outDir: path.join(outDir, "rsc"), + rollupOptions: { + input: { + index: ENTRY_SERVER_WRAPPER, + }, + output: OUTPUT_SERVER_JS_EXT, }, - output: OUTPUT_SERVER_JS_EXT, }, - }, + }; }; const rscParentPlugin: Plugin = { name: vitePluginReactServer.name, config(_config, env) { + tinyassert( + _config.build?.outDir, + "outDir is undefined in rscParentPlugin", + ); manager.configEnv = env; return { optimizeDeps: { @@ -285,7 +296,10 @@ export function vitePluginReactServer( }, build: { manifest: true, - outDir: env.isSsrBuild ? "dist/server" : "dist/client", + outDir: path.join( + _config.build.outDir, + env.isSsrBuild ? "server" : "client", + ), rollupOptions: env.isSsrBuild ? { input: options?.prerender @@ -303,6 +317,7 @@ export function vitePluginReactServer( }, configResolved(config) { manager.config = config; + manager.outDir = path.dirname(config.build.outDir); }, async configureServer(server) { manager.server = server; @@ -310,7 +325,9 @@ export function vitePluginReactServer( async buildStart(_options) { if (manager.configEnv.command === "serve") { tinyassert(manager.server); - const reactServer = await createServer(reactServerViteConfig); + const reactServer = await createServer( + createReactServerViteConfig(manager.outDir), + ); reactServer.pluginContainer.buildStart({}); $__global.dev = { server: manager.server, @@ -373,7 +390,10 @@ export function vitePluginReactServer( apply: "build", async buildStart(_options) { if (!manager.buildType) { - await createServerPackageJson(); + await createServerPackageJson(manager.outDir); + const reactServerViteConfig = createReactServerViteConfig( + manager.outDir, + ); console.log("▶▶▶ REACT SERVER BUILD (scan) [1/4]"); manager.buildType = "scan"; await build( diff --git a/packages/vite-plugin-ssr-middleware/src/plugin.ts b/packages/vite-plugin-ssr-middleware/src/plugin.ts index c96aa9598..5b0ed8518 100644 --- a/packages/vite-plugin-ssr-middleware/src/plugin.ts +++ b/packages/vite-plugin-ssr-middleware/src/plugin.ts @@ -1,3 +1,4 @@ +import path from "path"; import type { Connect, Plugin } from "vite"; import { name as packageName } from "../package.json"; @@ -10,6 +11,7 @@ export function vitePluginSsrMiddleware({ preview?: string; mode?: "ssrLoadModule" | "ViteRuntime" | "ViteRuntime-no-hmr"; }): Plugin { + let outDir: string = "dist"; return { name: packageName, @@ -39,6 +41,10 @@ export function vitePluginSsrMiddleware({ return; }, + configResolved(config) { + outDir = path.dirname(config.build.outDir); + }, + async configureServer(server) { let loadModule = server.ssrLoadModule; if (mode === "ViteRuntime" || mode === "ViteRuntime-no-hmr") { @@ -81,6 +87,9 @@ export function vitePluginSsrMiddleware({ async configurePreviewServer(server) { if (preview) { + if (!path.isAbsolute(preview)) { + preview = path.resolve(outDir, preview); + } const mod = await import(preview); return () => server.middlewares.use(mod.default); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bba7a4690..5d65a50ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,37 @@ importers: specifier: ^5.3.3 version: 5.3.3(@types/node@20.14.10)(terser@5.31.2) + packages/react-server/examples/custom-out-dir: + dependencies: + '@hiogawa/react-server': + specifier: workspace:* + version: link:../.. + next: + specifier: link:../../../react-server-next + version: link:../../../react-server-next + react: + specifier: 19.0.0-rc-df5f2736-20240712 + version: 19.0.0-rc-df5f2736-20240712 + react-dom: + specifier: 19.0.0-rc-df5f2736-20240712 + version: 19.0.0-rc-df5f2736-20240712(react@19.0.0-rc-df5f2736-20240712) + react-server-dom-webpack: + specifier: 19.0.0-rc-df5f2736-20240712 + version: 19.0.0-rc-df5f2736-20240712(react-dom@19.0.0-rc-df5f2736-20240712(react@19.0.0-rc-df5f2736-20240712))(react@19.0.0-rc-df5f2736-20240712)(webpack@5.93.0(@swc/core@1.6.13(@swc/helpers@0.5.12))(esbuild@0.23.0)) + devDependencies: + '@playwright/test': + specifier: ^1.45.1 + version: 1.45.1 + '@types/react': + specifier: 18.3.3 + version: 18.3.3 + '@types/react-dom': + specifier: 18.3.0 + version: 18.3.0 + vite: + specifier: ^5.3.3 + version: 5.3.3(@types/node@20.14.10)(terser@5.31.2) + packages/react-server/examples/minimal: dependencies: '@hiogawa/react-server': From b76b2a35d17532abaf6c1c6a7c70c7c3416fb2ef Mon Sep 17 00:00:00 2001 From: Johannes Waigel Date: Wed, 31 Jul 2024 02:31:47 +0200 Subject: [PATCH 2/6] fix: Ensure Vite respects custom outDir in build configuration --- packages/react-server-next/src/vite/index.ts | 3 +- .../examples/custom-out-dir/.gitignore | 1 + .../examples/custom-out-dir/app/page.tsx | 9 + .../custom-out-dir/e2e/out-dir.test.ts | 16 ++ .../examples/custom-out-dir/package.json | 26 ++ .../custom-out-dir/playwright.config.ts | 32 +++ .../examples/custom-out-dir/tsconfig.json | 17 ++ .../examples/custom-out-dir/vite.config.ts | 10 + packages/react-server/src/entry/ssr.tsx | 3 +- .../src/features/assets/plugin.ts | 10 +- .../react-server/src/features/next/plugin.ts | 7 +- .../src/features/prerender/plugin.ts | 17 +- .../src/features/router/plugin.ts | 14 +- packages/react-server/src/plugin/index.ts | 228 ++++++++++-------- .../vite-plugin-ssr-middleware/src/plugin.ts | 9 + pnpm-lock.yaml | 31 +++ 16 files changed, 306 insertions(+), 127 deletions(-) create mode 100644 packages/react-server/examples/custom-out-dir/.gitignore create mode 100644 packages/react-server/examples/custom-out-dir/app/page.tsx create mode 100644 packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts create mode 100644 packages/react-server/examples/custom-out-dir/package.json create mode 100644 packages/react-server/examples/custom-out-dir/playwright.config.ts create mode 100644 packages/react-server/examples/custom-out-dir/tsconfig.json create mode 100644 packages/react-server/examples/custom-out-dir/vite.config.ts diff --git a/packages/react-server-next/src/vite/index.ts b/packages/react-server-next/src/vite/index.ts index f4d9f0c52..c10081211 100644 --- a/packages/react-server-next/src/vite/index.ts +++ b/packages/react-server-next/src/vite/index.ts @@ -1,5 +1,4 @@ import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; import { type ReactServerPluginOptions, vitePluginReactServer, @@ -32,7 +31,7 @@ export default function vitePluginReactServerNext( vitePluginLogger(), vitePluginSsrMiddleware({ entry: "next/vite/entry-ssr", - preview: path.resolve("./dist/server/index.js"), + preview: "server/index.js", }), adapterPlugin({ adapter: options?.adapter }), appFaviconPlugin(), diff --git a/packages/react-server/examples/custom-out-dir/.gitignore b/packages/react-server/examples/custom-out-dir/.gitignore new file mode 100644 index 000000000..7c0a2a98b --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/.gitignore @@ -0,0 +1 @@ +custom-out-dir/ \ No newline at end of file diff --git a/packages/react-server/examples/custom-out-dir/app/page.tsx b/packages/react-server/examples/custom-out-dir/app/page.tsx new file mode 100644 index 000000000..029cbe897 --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/app/page.tsx @@ -0,0 +1,9 @@ +function page() { + return ( +
+

Hello from custom out dir!

+
+ ); +} + +export default page; diff --git a/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts b/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts new file mode 100644 index 000000000..2599220cb --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts @@ -0,0 +1,16 @@ +import fs from "fs"; +import { expect, test } from "@playwright/test"; +test("app renders", async ({ page }) => { + const res = await page.goto("/"); + expect(res?.status()).toBe(200); + expect(await page.textContent("h1")).toBe("Hello from custom out dir!"); +}); + +test("custom out directory should exist", () => { + const outDir = "custom-out-dir"; + expect(fs.existsSync(outDir)).toBe(true); +}); + +test("default out directory should not exist", () => { + expect(fs.existsSync("dist")).toBe(false); +}); diff --git a/packages/react-server/examples/custom-out-dir/package.json b/packages/react-server/examples/custom-out-dir/package.json new file mode 100644 index 000000000..bc88e0343 --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hiogawa/react-server-example-custom-out-dir", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test-e2e": "playwright test", + "test-e2e-preview": "E2E_PREVIEW=1 playwright test" + }, + "dependencies": { + "@hiogawa/react-server": "workspace:*", + "next": "link:../../../react-server-next", + "react": "rc", + "react-dom": "rc", + "react-server-dom-webpack": "rc" + }, + "devDependencies": { + "@playwright/test": "^1.45.1", + "@types/react": "latest", + "@types/react-dom": "latest", + "vite": "latest" + } +} diff --git a/packages/react-server/examples/custom-out-dir/playwright.config.ts b/packages/react-server/examples/custom-out-dir/playwright.config.ts new file mode 100644 index 000000000..6056ad83b --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.E2E_PORT || 6174); +const isPreview = Boolean(process.env.E2E_PREVIEW); +const command = isPreview + ? `pnpm start --port ${port} --strict-port` + : `pnpm dev --port ${port} --strict-port`; + +export default defineConfig({ + testDir: "e2e", + use: { + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: null, + deviceScaleFactor: undefined, + }, + }, + ], + webServer: { + command, + port, + }, + grepInvert: isPreview ? /@dev/ : /@build/, + forbidOnly: !!process.env["CI"], + retries: process.env["CI"] ? 2 : 0, + reporter: "list", +}); diff --git a/packages/react-server/examples/custom-out-dir/tsconfig.json b/packages/react-server/examples/custom-out-dir/tsconfig.json new file mode 100644 index 000000000..c66ba45e0 --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/tsconfig.json @@ -0,0 +1,17 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["next"], + "jsx": "react-jsx" + } +} diff --git a/packages/react-server/examples/custom-out-dir/vite.config.ts b/packages/react-server/examples/custom-out-dir/vite.config.ts new file mode 100644 index 000000000..b26804d10 --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/vite.config.ts @@ -0,0 +1,10 @@ +import next from "next/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [next()], + build: { + outDir: "custom-out-dir", + emptyOutDir: true, + }, +}); diff --git a/packages/react-server/src/entry/ssr.tsx b/packages/react-server/src/entry/ssr.tsx index 12d974285..cea0c8968 100644 --- a/packages/react-server/src/entry/ssr.tsx +++ b/packages/react-server/src/entry/ssr.tsx @@ -81,7 +81,8 @@ export async function importReactServer(): Promise { if (import.meta.env.DEV) { return $__global.dev.reactServer.ssrLoadModule(ENTRY_SERVER_WRAPPER) as any; } else { - return import("/dist/rsc/index.js" as string); + //TODO: FIND A WAY TO IMPORT RSC HERE + return import(`/dist/rsc/index.js` as string); } } diff --git a/packages/react-server/src/features/assets/plugin.ts b/packages/react-server/src/features/assets/plugin.ts index 300c28641..6cf5f55cc 100644 --- a/packages/react-server/src/features/assets/plugin.ts +++ b/packages/react-server/src/features/assets/plugin.ts @@ -65,7 +65,7 @@ export function vitePluginServerAssets({ // TODO: (refactor) use RouteManifest? const manifest: Manifest = JSON.parse( await fs.promises.readFile( - "dist/client/.vite/manifest.json", + path.join(manager.outDir, "client", ".vite", "manifest.json"), "utf-8", ), ); @@ -138,8 +138,8 @@ export function vitePluginServerAssets({ if (manager.buildType === "browser") { for (const file of manager.serverAssets) { await fs.promises.cp( - path.join("dist/rsc", file), - path.join("dist/client", file), + path.join(manager.outDir, "rsc", file), + path.join(manager.outDir, "client", file), ); } } @@ -150,7 +150,9 @@ export function vitePluginServerAssets({ export function serverAssertsPluginServer({ manager, -}: { manager: PluginStateManager }): Plugin[] { +}: { + manager: PluginStateManager; +}): Plugin[] { // 0. track server assets during server build (this plugin) // 1. copy all server assets to browser build (copy-build plugin) // 2. out of those, inject links automatically (ssr-assets virtual plugin) diff --git a/packages/react-server/src/features/next/plugin.ts b/packages/react-server/src/features/next/plugin.ts index b2e8e08ba..b67fb9309 100644 --- a/packages/react-server/src/features/next/plugin.ts +++ b/packages/react-server/src/features/next/plugin.ts @@ -1,4 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import type { Rollup } from "vite"; // ensure `.js` extension even if project root is cjs @@ -8,7 +9,7 @@ export const OUTPUT_SERVER_JS_EXT = { chunkFileNames: "assets/[name]-[hash].js", } satisfies Rollup.OutputOptions; -export async function createServerPackageJson() { - await mkdir("dist", { recursive: true }); - await writeFile("dist/package.json", `{ "type": "module" }`); +export async function createServerPackageJson(outDir: string) { + await mkdir(outDir, { recursive: true }); + await writeFile(path.join(outDir, "package.json"), `{ "type": "module" }`); } diff --git a/packages/react-server/src/features/prerender/plugin.ts b/packages/react-server/src/features/prerender/plugin.ts index 3b275d2d2..7abc7541d 100644 --- a/packages/react-server/src/features/prerender/plugin.ts +++ b/packages/react-server/src/features/prerender/plugin.ts @@ -39,7 +39,7 @@ export function prerenderPlugin({ writeBundle: { sequential: true, handler() { - return processPrerender(prerender); + return processPrerender(prerender, manager.outDir); }, }, }, @@ -66,10 +66,13 @@ function urlPathToHtmlPath(pathname: string) { return pathname + (pathname.endsWith("/") ? "index.html" : ".html"); } -async function processPrerender(getPrerenderRoutes: PrerenderFn) { +async function processPrerender( + getPrerenderRoutes: PrerenderFn, + outDir: string, +) { console.log("▶▶▶ PRERENDER"); const entry: typeof import("../../entry/ssr") = await import( - path.resolve("dist/server/__entry_ssr.js") + path.resolve(path.join(outDir, "server", "__entry_ssr.js")) ); const { router } = await entry.importReactServer(); const presets = createPrerenderPresets(router.manifest); @@ -87,11 +90,11 @@ async function processPrerender(getPrerenderRoutes: PrerenderFn) { const data = Readable.from(stream as any); const htmlFile = urlPathToHtmlPath(route); const dataFile = route + RSC_PATH; - await mkdir(path.dirname(path.join("dist/client", htmlFile)), { + await mkdir(path.dirname(path.join(outDir, "client", htmlFile)), { recursive: true, }); - await writeFile(path.join("dist/client", htmlFile), html); - await writeFile(path.join("dist/client", dataFile), data); + await writeFile(path.join(outDir, "client", htmlFile), html); + await writeFile(path.join(outDir, "client", dataFile), data); manifest.entries.push({ route, html: htmlFile, @@ -99,7 +102,7 @@ async function processPrerender(getPrerenderRoutes: PrerenderFn) { }); } await writeFile( - "dist/client/__prerender.json", + path.join(outDir, "client", "__prerender.json"), JSON.stringify(manifest, null, 2), ); } diff --git a/packages/react-server/src/features/router/plugin.ts b/packages/react-server/src/features/router/plugin.ts index 204a4b2d5..c058e7e60 100644 --- a/packages/react-server/src/features/router/plugin.ts +++ b/packages/react-server/src/features/router/plugin.ts @@ -24,7 +24,10 @@ import { createFsRouteTree } from "./tree"; export function routeManifestPluginServer({ manager, routeDir, -}: { manager: PluginStateManager; routeDir: string }): Plugin[] { +}: { + manager: PluginStateManager; + routeDir: string; +}): Plugin[] { return [ { name: "server-route-manifest", @@ -60,7 +63,9 @@ export function routeManifestPluginServer({ export function routeManifestPluginClient({ manager, -}: { manager: PluginStateManager }): Plugin[] { +}: { + manager: PluginStateManager; +}): Plugin[] { return [ { name: routeManifestPluginClient.name + ":bundle", @@ -96,7 +101,10 @@ export function routeManifestPluginClient({ const source = `${JSON.stringify(data, null, 2)}`; const sourceHash = hashString(source).slice(0, 8); const url = `/assets/route-manifest-${sourceHash}.js`; - writeFileSync(`dist/client${url}`, `export default ${source}`); + writeFileSync( + path.join(manager.outDir, `client${url}`), + `export default ${source}`, + ); // give asset url and manifest to ssr return `export default ${JSON.stringify( diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index 48c88babf..8abcee3a4 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -75,6 +75,8 @@ class PluginStateManager { config!: ResolvedConfig; configEnv!: ConfigEnv; + outDir!: string; + buildType?: "scan" | "server" | "browser" | "ssr"; routeToClientReferences: Record = {}; @@ -135,127 +137,133 @@ export function vitePluginReactServer( options?.entryServer ?? "@hiogawa/react-server/entry/server"; const routeDir = options?.routeDir ?? "src/routes"; - const reactServerViteConfig: InlineConfig = { - customLogger: createLogger(undefined, { - prefix: "[react-server]", - allowClearScreen: false, - }), - clearScreen: false, - configFile: false, - cacheDir: "./node_modules/.vite-rsc", - optimizeDeps: { - noDiscovery: true, - include: [], - }, - plugins: [ - ...(options?.plugins ?? []), - - vitePluginSilenceDirectiveBuildWarning(), - - // expose server reference to react-server itself - vitePluginServerUseServer({ - manager, - runtimePath: RUNTIME_SERVER_PATH, - }), - - // transform "use client" into client referecnes - vitePluginServerUseClient({ - manager, - runtimePath: RUNTIME_SERVER_PATH, - }), - - routeManifestPluginServer({ manager, routeDir }), - - createVirtualPlugin("server-routes", () => { - return ` - const glob = import.meta.glob( - "/${routeDir}/**/(page|layout|error|not-found|loading|template|route).(js|jsx|ts|tsx)", - { eager: true }, - ); - export default Object.fromEntries( - Object.entries(glob).map( - ([k, v]) => [k.slice("/${routeDir}".length), v] - ) - ); - - const globMiddleware = import.meta.glob("/middleware.(js|jsx|ts|tsx)", { eager: true }); - export const middleware = Object.values(globMiddleware)[0]; - `; + const createReactServerViteConfig = (outDir: string): InlineConfig => { + return { + customLogger: createLogger(undefined, { + prefix: "[react-server]", + allowClearScreen: false, }), + clearScreen: false, + configFile: false, + cacheDir: "./node_modules/.vite-rsc", + optimizeDeps: { + noDiscovery: true, + include: [], + }, + plugins: [ + ...(options?.plugins ?? []), - createVirtualPlugin( - ENTRY_SERVER_WRAPPER.slice("virtual:".length), - () => ` - import "virtual:inject-async-local-storage"; - export { handler } from "${entryServer}"; - export { router } from "@hiogawa/react-server/entry/server"; - `, - ), - - // make `AsyncLocalStorage` available globally for React.cache from edge build - // https://github.com/facebook/react/blob/f14d7f0d2597ea25da12bcf97772e8803f2a394c/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js#L16-L19 - createVirtualPlugin("inject-async-local-storage", () => { - if (options?.noAsyncLocalStorage) { - return "export {}"; - } - return ` - import { AsyncLocalStorage } from "node:async_hooks"; - Object.assign(globalThis, { AsyncLocalStorage }); - `; - }), + vitePluginSilenceDirectiveBuildWarning(), - validateImportPlugin({ - "client-only": `'client-only' is included in server build`, - "server-only": true, - }), + // expose server reference to react-server itself + vitePluginServerUseServer({ + manager, + runtimePath: RUNTIME_SERVER_PATH, + }), - serverAssertsPluginServer({ manager }), + // transform "use client" into client referecnes + vitePluginServerUseClient({ + manager, + runtimePath: RUNTIME_SERVER_PATH, + }), - serverDepsConfigPlugin(), + routeManifestPluginServer({ manager, routeDir }), - { - name: "patch-react-server-dom-webpack", - transform(code, id, _options) { - if (id.includes("react-server-dom-webpack")) { - // rename webpack markers in react server runtime - // to avoid conflict with ssr runtime which shares same globals - code = code.replaceAll( - "__webpack_require__", - "__vite_react_server_webpack_require__", + createVirtualPlugin("server-routes", () => { + return ` + const glob = import.meta.glob( + "/${routeDir}/**/(page|layout|error|not-found|loading|template|route).(js|jsx|ts|tsx)", + { eager: true }, ); - code = code.replaceAll( - "__webpack_chunk_load__", - "__vite_react_server_webpack_chunk_load__", + export default Object.fromEntries( + Object.entries(glob).map( + ([k, v]) => [k.slice("/${routeDir}".length), v] + ) ); - - // make server reference async for simplicity (stale chunkCache, etc...) - // see TODO in https://github.com/facebook/react/blob/33a32441e991e126e5e874f831bd3afc237a3ecf/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js#L131-L132 - code = code.replaceAll("if (isAsyncImport(metadata))", "if (true)"); - code = code.replaceAll("4 === metadata.length", "true"); - - return code; + + const globMiddleware = import.meta.glob("/middleware.(js|jsx|ts|tsx)", { eager: true }); + export const middleware = Object.values(globMiddleware)[0]; + `; + }), + + createVirtualPlugin( + ENTRY_SERVER_WRAPPER.slice("virtual:".length), + () => ` + import "virtual:inject-async-local-storage"; + export { handler } from "${entryServer}"; + export { router } from "@hiogawa/react-server/entry/server"; + `, + ), + + // make `AsyncLocalStorage` available globally for React.cache from edge build + // https://github.com/facebook/react/blob/f14d7f0d2597ea25da12bcf97772e8803f2a394c/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js#L16-L19 + createVirtualPlugin("inject-async-local-storage", () => { + if (options?.noAsyncLocalStorage) { + return "export {}"; } - return; + return ` + import { AsyncLocalStorage } from "node:async_hooks"; + Object.assign(globalThis, { AsyncLocalStorage }); + `; + }), + + validateImportPlugin({ + "client-only": `'client-only' is included in server build`, + "server-only": true, + }), + + serverAssertsPluginServer({ manager }), + + serverDepsConfigPlugin(), + + { + name: "patch-react-server-dom-webpack", + transform(code, id, _options) { + if (id.includes("react-server-dom-webpack")) { + // rename webpack markers in react server runtime + // to avoid conflict with ssr runtime which shares same globals + code = code.replaceAll( + "__webpack_require__", + "__vite_react_server_webpack_require__", + ); + code = code.replaceAll( + "__webpack_chunk_load__", + "__vite_react_server_webpack_chunk_load__", + ); + + // make server reference async for simplicity (stale chunkCache, etc...) + // see TODO in https://github.com/facebook/react/blob/33a32441e991e126e5e874f831bd3afc237a3ecf/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js#L131-L132 + code = code.replaceAll( + "if (isAsyncImport(metadata))", + "if (true)", + ); + code = code.replaceAll("4 === metadata.length", "true"); + + return code; + } + return; + }, }, - }, - ], - build: { - ssr: true, - manifest: true, - ssrEmitAssets: true, - outDir: "dist/rsc", - rollupOptions: { - input: { - index: ENTRY_SERVER_WRAPPER, + ], + build: { + ssr: true, + manifest: true, + ssrEmitAssets: true, + outDir: path.join(outDir, "rsc"), + rollupOptions: { + input: { + index: ENTRY_SERVER_WRAPPER, + }, + output: OUTPUT_SERVER_JS_EXT, }, - output: OUTPUT_SERVER_JS_EXT, }, - }, + }; }; const rscParentPlugin: Plugin = { name: vitePluginReactServer.name, config(_config, env) { + const outDir = _config.build?.outDir ?? "dist"; manager.configEnv = env; return { optimizeDeps: { @@ -285,7 +293,7 @@ export function vitePluginReactServer( }, build: { manifest: true, - outDir: env.isSsrBuild ? "dist/server" : "dist/client", + outDir: path.join(outDir, env.isSsrBuild ? "server" : "client"), rollupOptions: env.isSsrBuild ? { input: options?.prerender @@ -303,6 +311,7 @@ export function vitePluginReactServer( }, configResolved(config) { manager.config = config; + manager.outDir = path.dirname(config.build.outDir); }, async configureServer(server) { manager.server = server; @@ -310,7 +319,9 @@ export function vitePluginReactServer( async buildStart(_options) { if (manager.configEnv.command === "serve") { tinyassert(manager.server); - const reactServer = await createServer(reactServerViteConfig); + const reactServer = await createServer( + createReactServerViteConfig(manager.outDir), + ); reactServer.pluginContainer.buildStart({}); $__global.dev = { server: manager.server, @@ -373,7 +384,10 @@ export function vitePluginReactServer( apply: "build", async buildStart(_options) { if (!manager.buildType) { - await createServerPackageJson(); + await createServerPackageJson(manager.outDir); + const reactServerViteConfig = createReactServerViteConfig( + manager.outDir, + ); console.log("▶▶▶ REACT SERVER BUILD (scan) [1/4]"); manager.buildType = "scan"; await build( diff --git a/packages/vite-plugin-ssr-middleware/src/plugin.ts b/packages/vite-plugin-ssr-middleware/src/plugin.ts index c96aa9598..5b0ed8518 100644 --- a/packages/vite-plugin-ssr-middleware/src/plugin.ts +++ b/packages/vite-plugin-ssr-middleware/src/plugin.ts @@ -1,3 +1,4 @@ +import path from "path"; import type { Connect, Plugin } from "vite"; import { name as packageName } from "../package.json"; @@ -10,6 +11,7 @@ export function vitePluginSsrMiddleware({ preview?: string; mode?: "ssrLoadModule" | "ViteRuntime" | "ViteRuntime-no-hmr"; }): Plugin { + let outDir: string = "dist"; return { name: packageName, @@ -39,6 +41,10 @@ export function vitePluginSsrMiddleware({ return; }, + configResolved(config) { + outDir = path.dirname(config.build.outDir); + }, + async configureServer(server) { let loadModule = server.ssrLoadModule; if (mode === "ViteRuntime" || mode === "ViteRuntime-no-hmr") { @@ -81,6 +87,9 @@ export function vitePluginSsrMiddleware({ async configurePreviewServer(server) { if (preview) { + if (!path.isAbsolute(preview)) { + preview = path.resolve(outDir, preview); + } const mod = await import(preview); return () => server.middlewares.use(mod.default); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bba7a4690..5d65a50ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,37 @@ importers: specifier: ^5.3.3 version: 5.3.3(@types/node@20.14.10)(terser@5.31.2) + packages/react-server/examples/custom-out-dir: + dependencies: + '@hiogawa/react-server': + specifier: workspace:* + version: link:../.. + next: + specifier: link:../../../react-server-next + version: link:../../../react-server-next + react: + specifier: 19.0.0-rc-df5f2736-20240712 + version: 19.0.0-rc-df5f2736-20240712 + react-dom: + specifier: 19.0.0-rc-df5f2736-20240712 + version: 19.0.0-rc-df5f2736-20240712(react@19.0.0-rc-df5f2736-20240712) + react-server-dom-webpack: + specifier: 19.0.0-rc-df5f2736-20240712 + version: 19.0.0-rc-df5f2736-20240712(react-dom@19.0.0-rc-df5f2736-20240712(react@19.0.0-rc-df5f2736-20240712))(react@19.0.0-rc-df5f2736-20240712)(webpack@5.93.0(@swc/core@1.6.13(@swc/helpers@0.5.12))(esbuild@0.23.0)) + devDependencies: + '@playwright/test': + specifier: ^1.45.1 + version: 1.45.1 + '@types/react': + specifier: 18.3.3 + version: 18.3.3 + '@types/react-dom': + specifier: 18.3.0 + version: 18.3.0 + vite: + specifier: ^5.3.3 + version: 5.3.3(@types/node@20.14.10)(terser@5.31.2) + packages/react-server/examples/minimal: dependencies: '@hiogawa/react-server': From e842c87cc7289f8ac29a17b7ade7250bcc46972d Mon Sep 17 00:00:00 2001 From: Johannes Waigel Date: Thu, 1 Aug 2024 21:45:43 +0200 Subject: [PATCH 3/6] chore: RSC import with virtual-modules --- .../examples/custom-out-dir/vite.config.ts | 10 +- packages/react-server/src/entry/ssr.tsx | 19 +- .../src/features/prerender/plugin.ts | 4 +- packages/react-server/src/plugin/index.ts | 213 +++++++++--------- packages/react-server/src/plugin/virtual.d.ts | 4 + 5 files changed, 123 insertions(+), 127 deletions(-) diff --git a/packages/react-server/examples/custom-out-dir/vite.config.ts b/packages/react-server/examples/custom-out-dir/vite.config.ts index b26804d10..db2841234 100644 --- a/packages/react-server/examples/custom-out-dir/vite.config.ts +++ b/packages/react-server/examples/custom-out-dir/vite.config.ts @@ -2,9 +2,9 @@ import next from "next/vite"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [next()], - build: { - outDir: "custom-out-dir", - emptyOutDir: true, - }, + plugins: [ + next({ + outDir: "custom-out-dir", + }), + ], }); diff --git a/packages/react-server/src/entry/ssr.tsx b/packages/react-server/src/entry/ssr.tsx index 53691e102..cefdfd74c 100644 --- a/packages/react-server/src/entry/ssr.tsx +++ b/packages/react-server/src/entry/ssr.tsx @@ -1,3 +1,4 @@ +import { reactServerImport } from "virtual:import-react-server"; import { createDebug, tinyassert } from "@hiogawa/utils"; import { createMemoryHistory } from "@tanstack/history"; import ReactDOMServer from "react-dom/server.edge"; @@ -41,10 +42,7 @@ import type { ReactServerHandlerStreamResult } from "./server"; const debug = createDebug("react-server:ssr"); -export async function handler( - request: Request, - outDir: string, -): Promise { +export async function handler(request: Request): Promise { // dev only api endpoint to test internal if ( import.meta.env.DEV && @@ -53,7 +51,7 @@ export async function handler( return devInspectHandler(request); } - const reactServer = await importReactServer(outDir); + const reactServer = await importReactServer(); // server action and render rsc stream const result = await reactServer.handler({ request }); @@ -66,8 +64,8 @@ export async function handler( } // return stream and ssr at once for prerender -export async function prerender(request: Request, outDir: string) { - const reactServer = await importReactServer(outDir); +export async function prerender(request: Request) { + const reactServer = await importReactServer(); const result = await reactServer.handler({ request }); tinyassert(!(result instanceof Response)); @@ -80,14 +78,11 @@ export async function prerender(request: Request, outDir: string) { return { stream, response, html }; } -export async function importReactServer( - outDir: string, -): Promise { +export async function importReactServer(): Promise { if (import.meta.env.DEV) { return $__global.dev.reactServer.ssrLoadModule(ENTRY_SERVER_WRAPPER) as any; } else { - //TODO: FIND A WAY TO IMPORT RSC HERE - return import(`/dist/rsc/index.js` as string); + return reactServerImport; } } diff --git a/packages/react-server/src/features/prerender/plugin.ts b/packages/react-server/src/features/prerender/plugin.ts index 474e2f0e7..7abc7541d 100644 --- a/packages/react-server/src/features/prerender/plugin.ts +++ b/packages/react-server/src/features/prerender/plugin.ts @@ -74,7 +74,7 @@ async function processPrerender( const entry: typeof import("../../entry/ssr") = await import( path.resolve(path.join(outDir, "server", "__entry_ssr.js")) ); - const { router } = await entry.importReactServer(outDir); + const { router } = await entry.importReactServer(); const presets = createPrerenderPresets(router.manifest); const routes = await getPrerenderRoutes(router.manifest, presets); const manifest: PrerenderManifest = { entries: [] }; @@ -86,7 +86,7 @@ async function processPrerender( "x-react-server-render-mode": "prerender", }, }); - const { stream, html } = await entry.prerender(request, outDir); + const { stream, html } = await entry.prerender(request); const data = Readable.from(stream as any); const htmlFile = urlPathToHtmlPath(route); const dataFile = route + RSC_PATH; diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index 8abcee3a4..eb81bc5e2 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -56,13 +56,13 @@ const debug = createDebug("react-server:plugin"); // resolve import paths for `createClientReference`, `createServerReference`, etc... // since `import "@hiogawa/react-server"` is not always visible for exernal library. const RUNTIME_BROWSER_PATH = fileURLToPath( - new URL("../runtime/browser.js", import.meta.url), + new URL("../runtime/browser.js", import.meta.url) ); const RUNTIME_SSR_PATH = fileURLToPath( - new URL("../runtime/ssr.js", import.meta.url), + new URL("../runtime/ssr.js", import.meta.url) ); const RUNTIME_SERVER_PATH = fileURLToPath( - new URL("../runtime/server.js", import.meta.url), + new URL("../runtime/server.js", import.meta.url) ); export type { PrerenderManifest }; @@ -125,52 +125,52 @@ export type ReactServerPluginOptions = { entryBrowser?: string; entryServer?: string; routeDir?: string; + outDir?: string; noAsyncLocalStorage?: boolean; }; export function vitePluginReactServer( - options?: ReactServerPluginOptions, + options?: ReactServerPluginOptions ): Plugin[] { const entryBrowser = options?.entryBrowser ?? "@hiogawa/react-server/entry/browser"; const entryServer = options?.entryServer ?? "@hiogawa/react-server/entry/server"; const routeDir = options?.routeDir ?? "src/routes"; + const outDir = options?.outDir ?? "dist"; - const createReactServerViteConfig = (outDir: string): InlineConfig => { - return { - customLogger: createLogger(undefined, { - prefix: "[react-server]", - allowClearScreen: false, + const reactServerViteConfig: InlineConfig = { + customLogger: createLogger(undefined, { + prefix: "[react-server]", + allowClearScreen: false, + }), + clearScreen: false, + configFile: false, + cacheDir: "./node_modules/.vite-rsc", + optimizeDeps: { + noDiscovery: true, + include: [], + }, + plugins: [ + ...(options?.plugins ?? []), + vitePluginSilenceDirectiveBuildWarning(), + + // expose server reference to react-server itself + vitePluginServerUseServer({ + manager, + runtimePath: RUNTIME_SERVER_PATH, }), - clearScreen: false, - configFile: false, - cacheDir: "./node_modules/.vite-rsc", - optimizeDeps: { - noDiscovery: true, - include: [], - }, - plugins: [ - ...(options?.plugins ?? []), - vitePluginSilenceDirectiveBuildWarning(), - - // expose server reference to react-server itself - vitePluginServerUseServer({ - manager, - runtimePath: RUNTIME_SERVER_PATH, - }), - - // transform "use client" into client referecnes - vitePluginServerUseClient({ - manager, - runtimePath: RUNTIME_SERVER_PATH, - }), + // transform "use client" into client referecnes + vitePluginServerUseClient({ + manager, + runtimePath: RUNTIME_SERVER_PATH, + }), - routeManifestPluginServer({ manager, routeDir }), + routeManifestPluginServer({ manager, routeDir }), - createVirtualPlugin("server-routes", () => { - return ` + createVirtualPlugin("server-routes", () => { + return ` const glob = import.meta.glob( "/${routeDir}/**/(page|layout|error|not-found|loading|template|route).(js|jsx|ts|tsx)", { eager: true }, @@ -184,86 +184,81 @@ export function vitePluginReactServer( const globMiddleware = import.meta.glob("/middleware.(js|jsx|ts|tsx)", { eager: true }); export const middleware = Object.values(globMiddleware)[0]; `; - }), + }), - createVirtualPlugin( - ENTRY_SERVER_WRAPPER.slice("virtual:".length), - () => ` + createVirtualPlugin( + ENTRY_SERVER_WRAPPER.slice("virtual:".length), + () => ` import "virtual:inject-async-local-storage"; export { handler } from "${entryServer}"; export { router } from "@hiogawa/react-server/entry/server"; - `, - ), - - // make `AsyncLocalStorage` available globally for React.cache from edge build - // https://github.com/facebook/react/blob/f14d7f0d2597ea25da12bcf97772e8803f2a394c/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js#L16-L19 - createVirtualPlugin("inject-async-local-storage", () => { - if (options?.noAsyncLocalStorage) { - return "export {}"; - } - return ` + ` + ), + + // make `AsyncLocalStorage` available globally for React.cache from edge build + // https://github.com/facebook/react/blob/f14d7f0d2597ea25da12bcf97772e8803f2a394c/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js#L16-L19 + createVirtualPlugin("inject-async-local-storage", () => { + if (options?.noAsyncLocalStorage) { + return "export {}"; + } + return ` import { AsyncLocalStorage } from "node:async_hooks"; Object.assign(globalThis, { AsyncLocalStorage }); `; - }), - - validateImportPlugin({ - "client-only": `'client-only' is included in server build`, - "server-only": true, - }), - - serverAssertsPluginServer({ manager }), - - serverDepsConfigPlugin(), - - { - name: "patch-react-server-dom-webpack", - transform(code, id, _options) { - if (id.includes("react-server-dom-webpack")) { - // rename webpack markers in react server runtime - // to avoid conflict with ssr runtime which shares same globals - code = code.replaceAll( - "__webpack_require__", - "__vite_react_server_webpack_require__", - ); - code = code.replaceAll( - "__webpack_chunk_load__", - "__vite_react_server_webpack_chunk_load__", - ); - - // make server reference async for simplicity (stale chunkCache, etc...) - // see TODO in https://github.com/facebook/react/blob/33a32441e991e126e5e874f831bd3afc237a3ecf/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js#L131-L132 - code = code.replaceAll( - "if (isAsyncImport(metadata))", - "if (true)", - ); - code = code.replaceAll("4 === metadata.length", "true"); - - return code; - } - return; - }, + }), + + validateImportPlugin({ + "client-only": `'client-only' is included in server build`, + "server-only": true, + }), + + serverAssertsPluginServer({ manager }), + + serverDepsConfigPlugin(), + + { + name: "patch-react-server-dom-webpack", + transform(code, id, _options) { + if (id.includes("react-server-dom-webpack")) { + // rename webpack markers in react server runtime + // to avoid conflict with ssr runtime which shares same globals + code = code.replaceAll( + "__webpack_require__", + "__vite_react_server_webpack_require__" + ); + code = code.replaceAll( + "__webpack_chunk_load__", + "__vite_react_server_webpack_chunk_load__" + ); + + // make server reference async for simplicity (stale chunkCache, etc...) + // see TODO in https://github.com/facebook/react/blob/33a32441e991e126e5e874f831bd3afc237a3ecf/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js#L131-L132 + code = code.replaceAll("if (isAsyncImport(metadata))", "if (true)"); + code = code.replaceAll("4 === metadata.length", "true"); + + return code; + } + return; }, - ], - build: { - ssr: true, - manifest: true, - ssrEmitAssets: true, - outDir: path.join(outDir, "rsc"), - rollupOptions: { - input: { - index: ENTRY_SERVER_WRAPPER, - }, - output: OUTPUT_SERVER_JS_EXT, + }, + ], + build: { + ssr: true, + manifest: true, + ssrEmitAssets: true, + outDir: path.join(outDir, "rsc"), + rollupOptions: { + input: { + index: ENTRY_SERVER_WRAPPER, }, + output: OUTPUT_SERVER_JS_EXT, }, - }; + }, }; const rscParentPlugin: Plugin = { name: vitePluginReactServer.name, config(_config, env) { - const outDir = _config.build?.outDir ?? "dist"; manager.configEnv = env; return { optimizeDeps: { @@ -272,7 +267,7 @@ export function vitePluginReactServer( entries: [ path.posix.join( routeDir, - `**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)`, + `**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)` ), ], exclude: ["@hiogawa/react-server"], @@ -311,7 +306,7 @@ export function vitePluginReactServer( }, configResolved(config) { manager.config = config; - manager.outDir = path.dirname(config.build.outDir); + manager.outDir = outDir; }, async configureServer(server) { manager.server = server; @@ -319,9 +314,7 @@ export function vitePluginReactServer( async buildStart(_options) { if (manager.configEnv.command === "serve") { tinyassert(manager.server); - const reactServer = await createServer( - createReactServerViteConfig(manager.outDir), - ); + const reactServer = await createServer(reactServerViteConfig); reactServer.pluginContainer.buildStart({}); $__global.dev = { server: manager.server, @@ -385,15 +378,12 @@ export function vitePluginReactServer( async buildStart(_options) { if (!manager.buildType) { await createServerPackageJson(manager.outDir); - const reactServerViteConfig = createReactServerViteConfig( - manager.outDir, - ); console.log("▶▶▶ REACT SERVER BUILD (scan) [1/4]"); manager.buildType = "scan"; await build( mergeConfig(reactServerViteConfig, { build: { write: false }, - } satisfies InlineConfig), + } satisfies InlineConfig) ); console.log("▶▶▶ REACT SERVER BUILD (server) [2/4]"); manager.buildType = "server"; @@ -440,6 +430,13 @@ export function vitePluginReactServer( "client-only": true, "server-only": `'server-only' is included in client build`, }), + + createVirtualPlugin("import-react-server", () => { + return ` + export const reactServerImport = import("/${outDir}/rsc/index.js") + `; + }), + createVirtualPlugin(ENTRY_BROWSER_WRAPPER.slice("virtual:".length), () => { // dev if (!manager.buildType) { diff --git a/packages/react-server/src/plugin/virtual.d.ts b/packages/react-server/src/plugin/virtual.d.ts index e3cf4f693..8bda1bc3c 100644 --- a/packages/react-server/src/plugin/virtual.d.ts +++ b/packages/react-server/src/plugin/virtual.d.ts @@ -6,3 +6,7 @@ declare module "virtual:server-routes" { | import("../features/next/middleware").MiddlewareModule | undefined; } + +declare module "virtual:import-react-server" { + export const reactServerImport: Promise; +} From 9025ecff1bbeced07c731178ec26094e935fbb01 Mon Sep 17 00:00:00 2001 From: Johannes Waigel Date: Thu, 1 Aug 2024 22:14:42 +0200 Subject: [PATCH 4/6] chore: Use outDir in react-server-next plugin --- packages/react-server-next/src/vite/index.ts | 3 ++- packages/react-server/src/plugin/index.ts | 18 +++++++++--------- .../vite-plugin-ssr-middleware/src/plugin.ts | 9 --------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/react-server-next/src/vite/index.ts b/packages/react-server-next/src/vite/index.ts index c10081211..4103a94d4 100644 --- a/packages/react-server-next/src/vite/index.ts +++ b/packages/react-server-next/src/vite/index.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import { type ReactServerPluginOptions, vitePluginReactServer, @@ -31,7 +32,7 @@ export default function vitePluginReactServerNext( vitePluginLogger(), vitePluginSsrMiddleware({ entry: "next/vite/entry-ssr", - preview: "server/index.js", + preview: path.resolve(options?.outDir ?? "dist", "server", "index.js"), }), adapterPlugin({ adapter: options?.adapter }), appFaviconPlugin(), diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index eb81bc5e2..1b0699129 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -56,13 +56,13 @@ const debug = createDebug("react-server:plugin"); // resolve import paths for `createClientReference`, `createServerReference`, etc... // since `import "@hiogawa/react-server"` is not always visible for exernal library. const RUNTIME_BROWSER_PATH = fileURLToPath( - new URL("../runtime/browser.js", import.meta.url) + new URL("../runtime/browser.js", import.meta.url), ); const RUNTIME_SSR_PATH = fileURLToPath( - new URL("../runtime/ssr.js", import.meta.url) + new URL("../runtime/ssr.js", import.meta.url), ); const RUNTIME_SERVER_PATH = fileURLToPath( - new URL("../runtime/server.js", import.meta.url) + new URL("../runtime/server.js", import.meta.url), ); export type { PrerenderManifest }; @@ -130,7 +130,7 @@ export type ReactServerPluginOptions = { }; export function vitePluginReactServer( - options?: ReactServerPluginOptions + options?: ReactServerPluginOptions, ): Plugin[] { const entryBrowser = options?.entryBrowser ?? "@hiogawa/react-server/entry/browser"; @@ -192,7 +192,7 @@ export function vitePluginReactServer( import "virtual:inject-async-local-storage"; export { handler } from "${entryServer}"; export { router } from "@hiogawa/react-server/entry/server"; - ` + `, ), // make `AsyncLocalStorage` available globally for React.cache from edge build @@ -224,11 +224,11 @@ export function vitePluginReactServer( // to avoid conflict with ssr runtime which shares same globals code = code.replaceAll( "__webpack_require__", - "__vite_react_server_webpack_require__" + "__vite_react_server_webpack_require__", ); code = code.replaceAll( "__webpack_chunk_load__", - "__vite_react_server_webpack_chunk_load__" + "__vite_react_server_webpack_chunk_load__", ); // make server reference async for simplicity (stale chunkCache, etc...) @@ -267,7 +267,7 @@ export function vitePluginReactServer( entries: [ path.posix.join( routeDir, - `**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)` + `**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)`, ), ], exclude: ["@hiogawa/react-server"], @@ -383,7 +383,7 @@ export function vitePluginReactServer( await build( mergeConfig(reactServerViteConfig, { build: { write: false }, - } satisfies InlineConfig) + } satisfies InlineConfig), ); console.log("▶▶▶ REACT SERVER BUILD (server) [2/4]"); manager.buildType = "server"; diff --git a/packages/vite-plugin-ssr-middleware/src/plugin.ts b/packages/vite-plugin-ssr-middleware/src/plugin.ts index 5b0ed8518..c96aa9598 100644 --- a/packages/vite-plugin-ssr-middleware/src/plugin.ts +++ b/packages/vite-plugin-ssr-middleware/src/plugin.ts @@ -1,4 +1,3 @@ -import path from "path"; import type { Connect, Plugin } from "vite"; import { name as packageName } from "../package.json"; @@ -11,7 +10,6 @@ export function vitePluginSsrMiddleware({ preview?: string; mode?: "ssrLoadModule" | "ViteRuntime" | "ViteRuntime-no-hmr"; }): Plugin { - let outDir: string = "dist"; return { name: packageName, @@ -41,10 +39,6 @@ export function vitePluginSsrMiddleware({ return; }, - configResolved(config) { - outDir = path.dirname(config.build.outDir); - }, - async configureServer(server) { let loadModule = server.ssrLoadModule; if (mode === "ViteRuntime" || mode === "ViteRuntime-no-hmr") { @@ -87,9 +81,6 @@ export function vitePluginSsrMiddleware({ async configurePreviewServer(server) { if (preview) { - if (!path.isAbsolute(preview)) { - preview = path.resolve(outDir, preview); - } const mod = await import(preview); return () => server.middlewares.use(mod.default); } From 45e272f4b30a0fde887a18ae1e8f53f252f8ee63 Mon Sep 17 00:00:00 2001 From: Johannes Waigel Date: Fri, 2 Aug 2024 13:20:33 +0200 Subject: [PATCH 5/6] chore: Directly use virtual module and extend next with outDir --- .../src/vite/adapters/cloudflare/build.ts | 18 ++++++------ .../src/vite/adapters/index.ts | 7 +++-- .../src/vite/adapters/vercel/build.ts | 28 +++++++++++-------- packages/react-server-next/src/vite/index.ts | 5 ++-- .../examples/custom-out-dir/package.json | 5 +++- packages/react-server/src/entry/ssr.tsx | 3 +- packages/react-server/src/plugin/index.ts | 2 +- packages/react-server/src/plugin/virtual.d.ts | 2 +- 8 files changed, 40 insertions(+), 30 deletions(-) diff --git a/packages/react-server-next/src/vite/adapters/cloudflare/build.ts b/packages/react-server-next/src/vite/adapters/cloudflare/build.ts index ddfa383b2..30ed7fad7 100644 --- a/packages/react-server-next/src/vite/adapters/cloudflare/build.ts +++ b/packages/react-server-next/src/vite/adapters/cloudflare/build.ts @@ -3,23 +3,23 @@ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { PrerenderManifest } from "@hiogawa/react-server/plugin"; -export async function build() { - const buildDir = join(process.cwd(), "dist"); - const outDir = join(process.cwd(), "dist/cloudflare"); +export async function build({ outDir }: { outDir: string }) { + const buildDir = join(process.cwd(), outDir); + const adapterOutDir = join(process.cwd(), outDir, "cloudflare"); // clean - await rm(outDir, { recursive: true, force: true }); - await mkdir(outDir, { recursive: true }); + await rm(adapterOutDir, { recursive: true, force: true }); + await mkdir(adapterOutDir, { recursive: true }); // assets - await cp(join(buildDir, "client"), outDir, { + await cp(join(buildDir, "client"), adapterOutDir, { recursive: true, }); // worker routes // https://developers.cloudflare.com/pages/functions/routing/#create-a-_routesjson-file await writeFile( - join(outDir, "_routes.json"), + join(adapterOutDir, "_routes.json"), JSON.stringify( { version: 1, @@ -39,7 +39,7 @@ export async function build() { // headers // https://developers.cloudflare.com/pages/configuration/headers/ await writeFile( - join(outDir, "_headers"), + join(adapterOutDir, "_headers"), `\ /favicon.ico Cache-Control: public, max-age=3600, s-maxage=3600 @@ -53,7 +53,7 @@ export async function build() { const esbuild = await import("esbuild"); const result = await esbuild.build({ entryPoints: [join(buildDir, "server/index.js")], - outfile: join(outDir, "_worker.js"), + outfile: join(adapterOutDir, "_worker.js"), bundle: true, minify: true, metafile: true, diff --git a/packages/react-server-next/src/vite/adapters/index.ts b/packages/react-server-next/src/vite/adapters/index.ts index eddd28ae3..1987323bb 100644 --- a/packages/react-server-next/src/vite/adapters/index.ts +++ b/packages/react-server-next/src/vite/adapters/index.ts @@ -4,6 +4,7 @@ export type AdapterType = "node" | "vercel" | "vercel-edge" | "cloudflare"; export function adapterPlugin(options: { adapter?: AdapterType; + outDir: string; }): Plugin[] { const adapter = options.adapter ?? autoSelectAdapter(); if (adapter === "node") { @@ -31,15 +32,15 @@ export function adapterPlugin(options: { console.log(`▶▶▶ ADAPTER: ${adapter}`); if (adapter === "cloudflare") { const { build } = await import("./cloudflare/build"); - await build(); + await build({ outDir: options.outDir }); } if (adapter === "vercel") { const { build } = await import("./vercel/build"); - await build({ runtime: "node" }); + await build({ runtime: "node", outDir: options.outDir }); } if (adapter === "vercel-edge") { const { build } = await import("./vercel/build"); - await build({ runtime: "edge" }); + await build({ runtime: "edge", outDir: options.outDir }); } }, }, diff --git a/packages/react-server-next/src/vite/adapters/vercel/build.ts b/packages/react-server-next/src/vite/adapters/vercel/build.ts index 8c02c946b..bdabeb35c 100644 --- a/packages/react-server-next/src/vite/adapters/vercel/build.ts +++ b/packages/react-server-next/src/vite/adapters/vercel/build.ts @@ -38,13 +38,19 @@ const vcConfig = { type VercelRuntime = "node" | "edge"; -export async function build({ runtime }: { runtime: VercelRuntime }) { - const buildDir = join(process.cwd(), "dist"); - const outDir = join(process.cwd(), ".vercel/output"); +export async function build({ + runtime, + outDir, +}: { + runtime: VercelRuntime; + outDir: string; +}) { + const buildDir = join(process.cwd(), outDir); + const adapterOutDir = join(process.cwd(), ".vercel/output"); // clean - await rm(outDir, { recursive: true, force: true }); - await mkdir(outDir, { recursive: true }); + await rm(adapterOutDir, { recursive: true, force: true }); + await mkdir(adapterOutDir, { recursive: true }); // overrides for ssg html // https://vercel.com/docs/build-output-api/v3/configuration#overrides @@ -69,20 +75,20 @@ export async function build({ runtime }: { runtime: VercelRuntime }) { // config await writeFile( - join(outDir, "config.json"), + join(adapterOutDir, "config.json"), JSON.stringify(configJson, null, 2), ); // static - await mkdir(join(outDir, "static"), { recursive: true }); - await cp(join(buildDir, "client"), join(outDir, "static"), { + await mkdir(join(adapterOutDir, "static"), { recursive: true }); + await cp(join(buildDir, "client"), join(adapterOutDir, "static"), { recursive: true, }); // function config - await mkdir(join(outDir, "functions/index.func"), { recursive: true }); + await mkdir(join(adapterOutDir, "functions/index.func"), { recursive: true }); await writeFile( - join(outDir, "functions/index.func/.vc-config.json"), + join(adapterOutDir, "functions/index.func/.vc-config.json"), JSON.stringify(vcConfig[runtime], null, 2), ); @@ -90,7 +96,7 @@ export async function build({ runtime }: { runtime: VercelRuntime }) { const esbuild = await import("esbuild"); const result = await esbuild.build({ entryPoints: [join(buildDir, "server/index.js")], - outfile: join(outDir, "functions/index.func/index.mjs"), + outfile: join(adapterOutDir, "functions/index.func/index.mjs"), metafile: true, bundle: true, minify: true, diff --git a/packages/react-server-next/src/vite/index.ts b/packages/react-server-next/src/vite/index.ts index 4103a94d4..b3f08c623 100644 --- a/packages/react-server-next/src/vite/index.ts +++ b/packages/react-server-next/src/vite/index.ts @@ -20,6 +20,7 @@ export type ReactServerNextPluginOptions = { export default function vitePluginReactServerNext( options?: ReactServerPluginOptions & ReactServerNextPluginOptions, ): PluginOption { + const outDir = options?.outDir ?? "dist"; return [ react(), nextJsxPlugin(), @@ -32,9 +33,9 @@ export default function vitePluginReactServerNext( vitePluginLogger(), vitePluginSsrMiddleware({ entry: "next/vite/entry-ssr", - preview: path.resolve(options?.outDir ?? "dist", "server", "index.js"), + preview: path.resolve(outDir, "server", "index.js"), }), - adapterPlugin({ adapter: options?.adapter }), + adapterPlugin({ adapter: options?.adapter, outDir }), appFaviconPlugin(), { name: "next-exclude-optimize", diff --git a/packages/react-server/examples/custom-out-dir/package.json b/packages/react-server/examples/custom-out-dir/package.json index bc88e0343..c561f8213 100644 --- a/packages/react-server/examples/custom-out-dir/package.json +++ b/packages/react-server/examples/custom-out-dir/package.json @@ -5,8 +5,11 @@ "type": "module", "scripts": { "dev": "next dev", - "build": "next build", "start": "next start", + "build": "next build", + "cf-build": "CF_PAGES=1 pnpm build", + "cf-preview": "wrangler pages dev ./custom-out-dir/cloudflare --compatibility-date=2024-01-01 --compatibility-flags=nodejs_compat", + "vc-build": "VERCEL=1 pnpm build", "test-e2e": "playwright test", "test-e2e-preview": "E2E_PREVIEW=1 playwright test" }, diff --git a/packages/react-server/src/entry/ssr.tsx b/packages/react-server/src/entry/ssr.tsx index cefdfd74c..c2f75e7c9 100644 --- a/packages/react-server/src/entry/ssr.tsx +++ b/packages/react-server/src/entry/ssr.tsx @@ -1,4 +1,3 @@ -import { reactServerImport } from "virtual:import-react-server"; import { createDebug, tinyassert } from "@hiogawa/utils"; import { createMemoryHistory } from "@tanstack/history"; import ReactDOMServer from "react-dom/server.edge"; @@ -82,7 +81,7 @@ export async function importReactServer(): Promise { if (import.meta.env.DEV) { return $__global.dev.reactServer.ssrLoadModule(ENTRY_SERVER_WRAPPER) as any; } else { - return reactServerImport; + return import("virtual:import-react-server"); } } diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index 1b0699129..5f57e9256 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -433,7 +433,7 @@ export function vitePluginReactServer( createVirtualPlugin("import-react-server", () => { return ` - export const reactServerImport = import("/${outDir}/rsc/index.js") + export * from "/${outDir}/rsc/index.js"; `; }), diff --git a/packages/react-server/src/plugin/virtual.d.ts b/packages/react-server/src/plugin/virtual.d.ts index 8bda1bc3c..d25e6f1ff 100644 --- a/packages/react-server/src/plugin/virtual.d.ts +++ b/packages/react-server/src/plugin/virtual.d.ts @@ -8,5 +8,5 @@ declare module "virtual:server-routes" { } declare module "virtual:import-react-server" { - export const reactServerImport: Promise; + export const { handler, router }: typeof import("../entry/server"); } From e05fe41e91091f1907ad41942699078919941bf8 Mon Sep 17 00:00:00 2001 From: Johannes Waigel Date: Fri, 2 Aug 2024 16:11:11 +0200 Subject: [PATCH 6/6] chore: Execute e2e custom-out-dir test in CI --- .github/workflows/ci.yml | 6 ++++- .../examples/custom-out-dir/.gitignore | 5 +++- .../examples/custom-out-dir/app/page.tsx | 2 ++ .../examples/custom-out-dir/e2e/basic.test.ts | 23 +++++++++++++++++++ .../custom-out-dir/e2e/out-dir.test.ts | 16 ------------- .../examples/custom-out-dir/package.json | 5 ++-- .../custom-out-dir/playwright.config.ts | 5 +++- .../examples/custom-out-dir/tsconfig.json | 2 +- .../examples/custom-out-dir/vite.config.ts | 2 +- 9 files changed, 43 insertions(+), 23 deletions(-) create mode 100644 packages/react-server/examples/custom-out-dir/e2e/basic.test.ts delete mode 100644 packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df27a01cb..31b7271d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,11 @@ jobs: - run: pnpm -C packages/react-server/examples/prerender test-e2e-preview - run: pnpm -C packages/react-server/examples/prerender cf-build - run: pnpm -C packages/react-server/examples/prerender vc-build - + - run: pnpm -C packages/react-server/examples/custom-out-dir test-e2e + - run: pnpm -C packages/react-server/examples/custom-out-dir build + - run: pnpm -C packages/react-server/examples/custom-out-dir test-e2e-preview + - run: pnpm -C packages/react-server/examples/custom-out-dir cf-test-e2e-preview + test-react-server-package: runs-on: ubuntu-latest steps: diff --git a/packages/react-server/examples/custom-out-dir/.gitignore b/packages/react-server/examples/custom-out-dir/.gitignore index 7c0a2a98b..b8a118880 100644 --- a/packages/react-server/examples/custom-out-dir/.gitignore +++ b/packages/react-server/examples/custom-out-dir/.gitignore @@ -1 +1,4 @@ -custom-out-dir/ \ No newline at end of file +custom-out-dir/ +.vercel/ +.wrangler/ +dist-*/ \ No newline at end of file diff --git a/packages/react-server/examples/custom-out-dir/app/page.tsx b/packages/react-server/examples/custom-out-dir/app/page.tsx index 029cbe897..34cc3da60 100644 --- a/packages/react-server/examples/custom-out-dir/app/page.tsx +++ b/packages/react-server/examples/custom-out-dir/app/page.tsx @@ -1,7 +1,9 @@ function page() { + const outDir = import.meta.env.VITE_E2E_OUT_DIR; return (

Hello from custom out dir!

+ {outDir &&
{outDir}
}
); } diff --git a/packages/react-server/examples/custom-out-dir/e2e/basic.test.ts b/packages/react-server/examples/custom-out-dir/e2e/basic.test.ts new file mode 100644 index 000000000..f6c8d7a1e --- /dev/null +++ b/packages/react-server/examples/custom-out-dir/e2e/basic.test.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import { expect, test } from "@playwright/test"; +test("custom outDir app can be visited", async ({ page }) => { + const res = await page.goto("/"); + expect(res?.status()).toBe(200); + expect(await page.textContent("h1")).toBe("Hello from custom out dir!"); + if (process.env.VITE_E2E_OUT_DIR) { + expect(await page.textContent("pre")).toBe(process.env.VITE_E2E_OUT_DIR); + } +}); + +test("custom outDir is created", async () => { + test.skip( + !process.env.E2E_PREVIEW && !process.env.E2E_CF, + "outDir is not available in preview", + ); + const outDir = process.env.VITE_E2E_OUT_DIR || "custom-out-dir"; + expect(fs.existsSync(outDir)).toBe(true); +}); + +test("default outDir is not created", async () => { + expect(fs.existsSync("dist")).toBe(false); +}); diff --git a/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts b/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts deleted file mode 100644 index 2599220cb..000000000 --- a/packages/react-server/examples/custom-out-dir/e2e/out-dir.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import fs from "fs"; -import { expect, test } from "@playwright/test"; -test("app renders", async ({ page }) => { - const res = await page.goto("/"); - expect(res?.status()).toBe(200); - expect(await page.textContent("h1")).toBe("Hello from custom out dir!"); -}); - -test("custom out directory should exist", () => { - const outDir = "custom-out-dir"; - expect(fs.existsSync(outDir)).toBe(true); -}); - -test("default out directory should not exist", () => { - expect(fs.existsSync("dist")).toBe(false); -}); diff --git a/packages/react-server/examples/custom-out-dir/package.json b/packages/react-server/examples/custom-out-dir/package.json index c561f8213..c551968c4 100644 --- a/packages/react-server/examples/custom-out-dir/package.json +++ b/packages/react-server/examples/custom-out-dir/package.json @@ -8,10 +8,11 @@ "start": "next start", "build": "next build", "cf-build": "CF_PAGES=1 pnpm build", - "cf-preview": "wrangler pages dev ./custom-out-dir/cloudflare --compatibility-date=2024-01-01 --compatibility-flags=nodejs_compat", + "cf-preview": "wrangler pages dev ./dist-cf/cloudflare --compatibility-date=2024-01-01 --compatibility-flags=nodejs_compat", "vc-build": "VERCEL=1 pnpm build", "test-e2e": "playwright test", - "test-e2e-preview": "E2E_PREVIEW=1 playwright test" + "test-e2e-preview": "E2E_PREVIEW=1 playwright test", + "cf-test-e2e-preview": "VITE_E2E_OUT_DIR=dist-cf pnpm cf-build && VITE_E2E_OUT_DIR=dist-cf E2E_CF=1 pnpm test-e2e" }, "dependencies": { "@hiogawa/react-server": "workspace:*", diff --git a/packages/react-server/examples/custom-out-dir/playwright.config.ts b/packages/react-server/examples/custom-out-dir/playwright.config.ts index 6056ad83b..a9b8be342 100644 --- a/packages/react-server/examples/custom-out-dir/playwright.config.ts +++ b/packages/react-server/examples/custom-out-dir/playwright.config.ts @@ -1,11 +1,14 @@ import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.E2E_PORT || 6174); +const isCloudflarePages = Boolean(process.env.CF_PAGES); const isPreview = Boolean(process.env.E2E_PREVIEW); const command = isPreview ? `pnpm start --port ${port} --strict-port` : `pnpm dev --port ${port} --strict-port`; +const cfPreview = `pnpm run cf-preview --port ${port}`; + export default defineConfig({ testDir: "e2e", use: { @@ -22,7 +25,7 @@ export default defineConfig({ }, ], webServer: { - command, + command: isCloudflarePages ? cfPreview : command, port, }, grepInvert: isPreview ? /@dev/ : /@build/, diff --git a/packages/react-server/examples/custom-out-dir/tsconfig.json b/packages/react-server/examples/custom-out-dir/tsconfig.json index c66ba45e0..51a93621c 100644 --- a/packages/react-server/examples/custom-out-dir/tsconfig.json +++ b/packages/react-server/examples/custom-out-dir/tsconfig.json @@ -11,7 +11,7 @@ "module": "ESNext", "target": "ESNext", "lib": ["ESNext", "DOM"], - "types": ["next"], + "types": ["next", "vitest/globals"], "jsx": "react-jsx" } } diff --git a/packages/react-server/examples/custom-out-dir/vite.config.ts b/packages/react-server/examples/custom-out-dir/vite.config.ts index db2841234..65e519d6e 100644 --- a/packages/react-server/examples/custom-out-dir/vite.config.ts +++ b/packages/react-server/examples/custom-out-dir/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ next({ - outDir: "custom-out-dir", + outDir: process.env.VITE_E2E_OUT_DIR || "custom-out-dir", }), ], });