diff --git a/packages/react-server-next/src/vite/adapters/index.ts b/packages/react-server-next/src/vite/adapters/index.ts index f8357039c..3381e286c 100644 --- a/packages/react-server-next/src/vite/adapters/index.ts +++ b/packages/react-server-next/src/vite/adapters/index.ts @@ -6,10 +6,10 @@ export type AdapterType = "node" | "vercel" | "vercel-edge" | "cloudflare"; export function adapterPlugin(options: { adapter: AdapterType; outDir: string; -}): { server?: Plugin[]; client?: Plugin[] } { +}): Plugin[] { const adapter = options.adapter ?? autoSelectAdapter(); if (adapter === "node") { - return {}; + return []; } const buildPlugin: Plugin = { @@ -102,10 +102,12 @@ export function adapterPlugin(options: { }, }; - return { - server: [aliasPlatformPlugin], - client: [registerHooksPlugin, buildPlugin, devPlatformPlugin], - }; + return [ + registerHooksPlugin, + buildPlugin, + devPlatformPlugin, + aliasPlatformPlugin, + ]; } // cf. https://github.com/sveltejs/kit/blob/52e5461b055a104694f276859a7104f58452fab0/packages/adapter-auto/adapters.js diff --git a/packages/react-server-next/src/vite/index.ts b/packages/react-server-next/src/vite/index.ts index 034d126fc..229bcea05 100644 --- a/packages/react-server-next/src/vite/index.ts +++ b/packages/react-server-next/src/vite/index.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { type ReactServerPluginOptions, vitePluginReactServer, + wrapServerPlugin, } from "@hiogawa/react-server/plugin"; import { vitePluginFetchUrlImportMetaUrl, @@ -32,10 +33,6 @@ export default function vitePluginReactServerNext( ): PluginOption { const outDir = options?.outDir ?? "dist"; const adapter = options?.adapter ?? autoSelectAdapter(); - const adapterPlugins = adapterPlugin({ - adapter, - outDir, - }); return [ react(), @@ -45,34 +42,33 @@ export default function vitePluginReactServerNext( vitePluginReactServer({ ...options, routeDir: options?.routeDir ?? "app", - plugins: [ - nextJsxPlugin(), - tsconfigPaths(), - nextOgPlugin(), - vitePluginWasmModule({ - buildMode: - adapter === "cloudflare" || adapter === "vercel-edge" - ? "import" - : "fs", - }), - vitePluginFetchUrlImportMetaUrl({ - buildMode: - adapter === "cloudflare" - ? "import" - : adapter === "vercel-edge" - ? "inline" - : "fs", - }), - adapterPlugins.server, - options?.plugins, - ], }), + nextOgPlugin(), + wrapServerPlugin([ + vitePluginWasmModule({ + buildMode: + adapter === "cloudflare" || adapter === "vercel-edge" + ? "import" + : "fs", + }), + vitePluginFetchUrlImportMetaUrl({ + buildMode: + adapter === "cloudflare" + ? "import" + : adapter === "vercel-edge" + ? "inline" + : "fs", + }), + ]), vitePluginLogger(), vitePluginSsrMiddleware({ entry: "next/vite/entry-ssr", preview: path.resolve(outDir, "server", "index.js"), }), - adapterPlugins.client, + adapterPlugin({ + adapter, + outDir, + }), appFaviconPlugin(), { name: "next-exclude-optimize", @@ -115,10 +111,19 @@ function nextOgPlugin(): Plugin[] { ]; } +// workaround https://github.com/vitejs/vite/issues/17689 +(globalThis as any).__next_vite_last_env__ ??= []; +declare let __next_vite_last_env__: string[]; + function nextConfigPlugin(): Plugin { return { name: nextConfigPlugin.name, config() { + // remove last loaded env so that Vite reloads a new value + for (const key of __next_vite_last_env__) { + delete process.env[key]; + } + // TODO // this is only for import.meta.env.NEXT_PUBLIC_xxx replacement. // we might want to define process.env.NEXT_PUBLIC_xxx for better compatibility. @@ -128,26 +133,15 @@ function nextConfigPlugin(): Plugin { }; }, configResolved(config) { - updateEnv(() => loadEnv(config.mode, config.envDir, "")); + const loadedEnv = loadEnv(config.mode, config.envDir, ""); + __next_vite_last_env__ = Object.keys(loadedEnv).filter( + (key) => !(key in process.env), + ); + Object.assign(process.env, loadedEnv); }, }; } -// workaround https://github.com/vitejs/vite/issues/17689 -(globalThis as any).__next_vite_last_env__ ??= []; -declare let __next_vite_last_env__: string[]; - -function updateEnv(loadEnv: () => Record) { - for (const key of __next_vite_last_env__) { - delete process.env[key]; - } - const loadedEnv = loadEnv(); - __next_vite_last_env__ = Object.keys(loadedEnv).filter( - (key) => !(key in process.env), - ); - Object.assign(process.env, loadedEnv); -} - function nextJsxPlugin(): Plugin { return { name: nextJsxPlugin.name, diff --git a/packages/react-server/examples/basic/e2e/helper.ts b/packages/react-server/examples/basic/e2e/helper.ts index 3eb050abd..d48b8b69d 100644 --- a/packages/react-server/examples/basic/e2e/helper.ts +++ b/packages/react-server/examples/basic/e2e/helper.ts @@ -87,6 +87,7 @@ export async function inspectDevModules( } export const testNoJs = test.extend({ + // @ts-ignore javaScriptEnabled: ({}, use) => use(false), }); diff --git a/packages/react-server/examples/basic/vite.config.ts b/packages/react-server/examples/basic/vite.config.ts index c2d2ab70b..45fe0a4e4 100644 --- a/packages/react-server/examples/basic/vite.config.ts +++ b/packages/react-server/examples/basic/vite.config.ts @@ -1,5 +1,9 @@ import path from "node:path"; -import { vitePluginReactServer } from "@hiogawa/react-server/plugin"; +import { + vitePluginReactServer, + wrapClientPlugin, + wrapServerPlugin, +} from "@hiogawa/react-server/plugin"; import { vitePluginErrorOverlay } from "@hiogawa/vite-plugin-error-overlay"; import { vitePluginWasmModule } from "@hiogawa/vite-plugin-server-asset"; import { @@ -14,10 +18,14 @@ import { type Plugin, defineConfig } from "vite"; export default defineConfig({ clearScreen: false, plugins: [ + // TODO: for now mdx is server only. + // see https://mdxjs.com/docs/getting-started/#vite for how to setup client hmr. + mdx(), process.env["USE_SWC"] ? (await import("@vitejs/plugin-react-swc".slice())).default() : react(), - unocss(), + // TODO: remove from ssr build + wrapClientPlugin(unocss()), !process.env["E2E"] && vitePluginErrorOverlay({ patchConsoleError: true, @@ -25,29 +33,6 @@ export default defineConfig({ vitePluginReactServer({ entryBrowser: "/src/entry-browser", entryServer: "/src/entry-server", - plugins: [ - // TODO: for now mdx is server only. - // see https://mdxjs.com/docs/getting-started/#vite for how to setup client hmr. - mdx(), - testVitePluginVirtual(), - vitePluginWasmModule({ - buildMode: - process.env.VERCEL || process.env.CF_PAGES ? "import" : "fs", - }), - { - name: "cusotm-react-server-config", - config() { - return { - build: { - assetsInlineLimit(filePath) { - // test non-inlined server asset - return !filePath.includes("/test/assets/"); - }, - }, - }; - }, - }, - ], }), vitePluginLogger(), vitePluginSsrMiddleware({ @@ -66,9 +51,18 @@ export default defineConfig({ }, }, testVitePluginVirtual(), + wrapServerPlugin([ + vitePluginWasmModule({ + buildMode: process.env.VERCEL || process.env.CF_PAGES ? "import" : "fs", + }), + ]), ], build: { ssrEmitAssets: true, + assetsInlineLimit(filePath) { + // test non-inlined server asset + return !filePath.includes("/test/assets/"); + }, }, ssr: { noExternal: [ diff --git a/packages/react-server/src/entry/ssr.tsx b/packages/react-server/src/entry/ssr.tsx index 317b3fc58..081628670 100644 --- a/packages/react-server/src/entry/ssr.tsx +++ b/packages/react-server/src/entry/ssr.tsx @@ -1,7 +1,7 @@ import { createDebug, tinyassert } from "@hiogawa/utils"; import { createMemoryHistory } from "@tanstack/history"; import ReactDOMServer from "react-dom/server.edge"; -import type { ModuleNode, ViteDevServer } from "vite"; +import type { DevEnvironment, EnvironmentModuleNode } from "vite"; import type { SsrAssetsType } from "../features/assets/plugin"; import { DEV_SSR_CSS, SERVER_CSS_PROXY } from "../features/assets/shared"; import { @@ -79,7 +79,7 @@ export async function prerender(request: Request) { export async function importReactServer(): Promise { if (import.meta.env.DEV) { - return $__global.dev.reactServer.ssrLoadModule(ENTRY_SERVER_WRAPPER) as any; + return $__global.dev.reactServerRunner.import(ENTRY_SERVER_WRAPPER); } else { return import("virtual:react-server-build" as string); } @@ -264,21 +264,24 @@ async function devInspectHandler(request: Request) { tinyassert(request.method === "POST"); const data = await request.json(); if (data.type === "module") { - let mod: ModuleNode | undefined; + let mod: EnvironmentModuleNode | undefined; if (data.environment === "ssr") { - mod = await getModuleNode($__global.dev.server, data.url, true); + mod = await getModuleNode( + $__global.dev.server.environments.ssr, + data.url, + ); } - if (data.environment === "react-server") { - mod = await getModuleNode($__global.dev.reactServer, data.url, true); + if (data.environment === "rsc") { + mod = await getModuleNode( + $__global.dev.server.environments["rsc"]!, + data.url, + ); } const result = mod && { id: mod.id, lastInvalidationTimestamp: mod.lastInvalidationTimestamp, importers: [...(mod.importers ?? [])].map((m) => m.id), - ssrImportedModules: [...(mod.ssrImportedModules ?? [])].map((m) => m.id), - clientImportedModules: [...(mod.clientImportedModules ?? [])].map( - (m) => m.id, - ), + importedModules: [...(mod.importedModules ?? [])].map((m) => m.id), }; return new Response(JSON.stringify(result || false, null, 2), { headers: { "content-type": "application/json" }, @@ -287,8 +290,8 @@ async function devInspectHandler(request: Request) { tinyassert(false); } -async function getModuleNode(server: ViteDevServer, url: string, ssr: boolean) { - const resolved = await server.moduleGraph.resolveUrl(url, ssr); +async function getModuleNode(server: DevEnvironment, url: string) { + const resolved = await server.moduleGraph.resolveUrl(url); return server.moduleGraph.getModuleById(resolved[1]); } diff --git a/packages/react-server/src/features/assets/css.ts b/packages/react-server/src/features/assets/css.ts index 2b68de6ef..01b768079 100644 --- a/packages/react-server/src/features/assets/css.ts +++ b/packages/react-server/src/features/assets/css.ts @@ -1,14 +1,17 @@ -import type { ViteDevServer } from "vite"; +import { + type DevEnvironment, + type EnvironmentModuleNode, + isCSSRequest, +} from "vite"; // cf // https://github.com/hi-ogawa/vite-plugins/blob/3c496fa1bb5ac66d2880986877a37ed262f1d2a6/packages/vite-glob-routes/examples/demo/vite-plugin-ssr-css.ts // https://github.com/remix-run/remix/blob/dev/packages/remix-dev/vite/styles.ts -export async function collectStyle( - server: ViteDevServer, - options: { entries: string[]; ssr: boolean }, +export async function transformStyleUrls( + server: DevEnvironment, + urls: string[], ) { - const urls = await collectStyleUrls(server, options); const styles = await Promise.all( urls.map(async (url) => { const res = await server.transformRequest(url + "?direct"); @@ -19,35 +22,28 @@ export async function collectStyle( } export async function collectStyleUrls( - server: ViteDevServer, - { entries, ssr }: { entries: string[]; ssr: boolean }, + server: DevEnvironment, + { entries }: { entries: string[] }, ) { - const visited = new Set(); + const visited = new Set(); async function traverse(url: string) { const [, id] = await server.moduleGraph.resolveUrl(url); - if (visited.has(id)) { - return; - } - visited.add(id); const mod = server.moduleGraph.getModuleById(id); - if (!mod) { + if (!mod || visited.has(mod)) { return; } + visited.add(mod); await Promise.all( [...mod.importedModules].map((childMod) => traverse(childMod.url)), ); } // ensure import analysis is ready for top entries - await Promise.all(entries.map((e) => server.transformRequest(e, { ssr }))); + await Promise.all(entries.map((e) => server.transformRequest(e))); // traverse await Promise.all(entries.map((url) => traverse(url))); - return [...visited].filter((url) => url.match(CSS_LANGS_RE)); + return [...visited].map((mod) => mod.url).filter((url) => isCSSRequest(url)); } - -// cf. https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/constants.ts#L49C23-L50 -export const CSS_LANGS_RE = - /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; diff --git a/packages/react-server/src/features/assets/plugin.ts b/packages/react-server/src/features/assets/plugin.ts index 3d407ec6c..ede41bfc4 100644 --- a/packages/react-server/src/features/assets/plugin.ts +++ b/packages/react-server/src/features/assets/plugin.ts @@ -5,7 +5,7 @@ import type { Manifest, Plugin, ViteDevServer } from "vite"; import { $__global } from "../../global"; import type { PluginStateManager } from "../../plugin"; import { ENTRY_BROWSER_WRAPPER, createVirtualPlugin } from "../../plugin/utils"; -import { collectStyle, collectStyleUrls } from "./css"; +import { collectStyleUrls, transformStyleUrls } from "./css"; import { DEV_SSR_CSS, SERVER_CSS_PROXY } from "./shared"; export interface SsrAssetsType { @@ -103,22 +103,34 @@ export function vitePluginServerAssets({ return file.startsWith(root) ? file.slice(root.length) : file; }, ); - const styles = await Promise.all([ - `/******* react-server ********/`, - collectStyle($__global.dev.reactServer, { + const serverStyleUrls = await collectStyleUrls( + $__global.dev.server.environments["rsc"]!, + { entries: [entryServer, "virtual:server-routes"], - ssr: true, - }), - `/******* client **************/`, - collectStyle($__global.dev.server, { + }, + ); + const clientStyleUrls = await collectStyleUrls( + $__global.dev.server.environments.client, + { entries: [ entryBrowser, "virtual:client-routes", // TODO: dev should also use RouteManifest to manage client css ...clientReferences, ], - ssr: false, - }), + }, + ); + const styles = await Promise.all([ + `/******* react-server ********/`, + await transformStyleUrls( + $__global.dev.server.environments.client, + serverStyleUrls, + ), + `/******* client **************/`, + await transformStyleUrls( + $__global.dev.server.environments.client, + clientStyleUrls, + ), ]); return styles.join("\n\n"); }), @@ -127,10 +139,12 @@ export function vitePluginServerAssets({ // virtual module to proxy css imports from react server to client // TODO: invalidate + full reload when add/remove css file? if (!manager.buildType) { - const urls = await collectStyleUrls($__global.dev.reactServer, { - entries: [entryServer, "virtual:server-routes"], - ssr: true, - }); + const urls = await collectStyleUrls( + $__global.dev.server.environments["rsc"]!, + { + entries: [entryServer, "virtual:server-routes"], + }, + ); const code = urls.map((url) => `import "${url}";\n`).join(""); // ensure hmr boundary since css module doesn't have `import.meta.hot.accept` return code + `if (import.meta.hot) { import.meta.hot.accept() }`; @@ -157,7 +171,7 @@ export function vitePluginServerAssets({ ]; } -export function serverAssertsPluginServer({ +export function serverAssetsPluginServer({ manager, }: { manager: PluginStateManager }): Plugin[] { // 0. track server assets during server build (this plugin) @@ -172,7 +186,7 @@ export function serverAssertsPluginServer({ return [ { - name: serverAssertsPluginServer.name + ":build", + name: serverAssetsPluginServer.name + ":build", apply: "build", generateBundle(_options, bundle) { if (manager.buildType !== "server") { diff --git a/packages/react-server/src/features/client-component/plugin.ts b/packages/react-server/src/features/client-component/plugin.ts index fd52c435f..83d8189d4 100644 --- a/packages/react-server/src/features/client-component/plugin.ts +++ b/packages/react-server/src/features/client-component/plugin.ts @@ -5,17 +5,14 @@ import { transformDirectiveProxyExport, } from "@hiogawa/transforms"; import { createDebug, memoize, tinyassert } from "@hiogawa/utils"; -import { - type Plugin, - type PluginOption, - type ViteDevServer, - parseAstAsync, -} from "vite"; +import { type Plugin, type ViteDevServer, parseAstAsync } from "vite"; import type { PluginStateManager } from "../../plugin"; import { type CustomModuleMeta, USE_CLIENT, USE_CLIENT_RE, + applyPluginToClient, + applyPluginToServer, createVirtualPlugin, } from "../../plugin/utils"; @@ -38,7 +35,7 @@ export function vitePluginServerUseClient({ }: { manager: PluginStateManager; runtimePath: string; -}): PluginOption { +}): Plugin[] { // TODO: // eventually we should try entirely virtual module approach for client reference (not only node_modules) // so that we can delegate precise resolution (e.g. `?v=` deps optimization hash, `?t=` hmr timestamp) @@ -49,6 +46,7 @@ export function vitePluginServerUseClient({ name: "server-virtual-use-client-node-modules", enforce: "pre", // "pre" to steal Vite's node resolve apply: "serve", + applyToEnvironment: applyPluginToServer, resolveId: memoize(async function (this, source, importer) { if ( source[0] !== "." && @@ -130,6 +128,7 @@ export function vitePluginServerUseClient({ const useClientPlugin: Plugin = { name: vitePluginServerUseClient.name, + applyToEnvironment: applyPluginToServer, async transform(code, id, _options) { // when using external library's server component includes client reference, // it will end up here with deps optimization hash `?v=` resolved by server module graph. @@ -181,6 +180,7 @@ export function vitePluginServerUseClient({ name: vitePluginServerUseClient + ":strip-strip", apply: "build", enforce: "post", + applyToEnvironment: applyPluginToServer, async buildStart() { if (manager.buildType !== "scan") return; @@ -254,6 +254,7 @@ export function vitePluginClientUseClient({ const devExternalPlugin: Plugin = { name: vitePluginClientUseClient.name + ":dev-external", apply: "serve", + applyToEnvironment: applyPluginToClient, resolveId(source, _importer, _options) { if (source.startsWith(VIRTUAL_PREFIX)) { return "\0" + source; diff --git a/packages/react-server/src/features/router/plugin.ts b/packages/react-server/src/features/router/plugin.ts index a1ec1a9a0..a531be3e2 100644 --- a/packages/react-server/src/features/router/plugin.ts +++ b/packages/react-server/src/features/router/plugin.ts @@ -29,8 +29,8 @@ export function routeManifestPluginServer({ { name: "server-route-manifest", apply: "build", - async buildEnd() { - if (manager.buildType === "server") { + async buildEnd(error) { + if (!error && manager.buildType === "server") { const routeFiles = await FastGlob( path.posix.join( routeDir, diff --git a/packages/react-server/src/features/server-action/plugin.tsx b/packages/react-server/src/features/server-action/plugin.tsx index 999d6ab03..c6d8bcb97 100644 --- a/packages/react-server/src/features/server-action/plugin.tsx +++ b/packages/react-server/src/features/server-action/plugin.tsx @@ -3,9 +3,14 @@ import { transformServerActionServer, } from "@hiogawa/transforms"; import { createDebug, tinyassert } from "@hiogawa/utils"; -import { type Plugin, type PluginOption, parseAstAsync } from "vite"; +import { type Plugin, parseAstAsync } from "vite"; import type { PluginStateManager } from "../../plugin"; -import { USE_SERVER, createVirtualPlugin } from "../../plugin/utils"; +import { + USE_SERVER, + applyPluginToClient, + applyPluginToServer, + createVirtualPlugin, +} from "../../plugin/utils"; const debug = createDebug("react-server:plugin:server-action"); @@ -30,6 +35,7 @@ export function vitePluginClientUseServer({ }): Plugin { return { name: vitePluginClientUseServer.name, + applyToEnvironment: applyPluginToClient, async transform(code, id, options) { if (!code.includes(USE_SERVER)) { manager.serverReferenceMap.delete(id); @@ -85,9 +91,10 @@ export function vitePluginServerUseServer({ }: { manager: PluginStateManager; runtimePath: string; -}): PluginOption { +}): Plugin[] { const transformPlugin: Plugin = { name: vitePluginServerUseServer.name, + applyToEnvironment: applyPluginToServer, async transform(code, id, _options) { manager.serverReferenceMap.delete(id); if (!code.includes(USE_SERVER)) { diff --git a/packages/react-server/src/global.ts b/packages/react-server/src/global.ts index 4e4f6fa63..796d0d704 100644 --- a/packages/react-server/src/global.ts +++ b/packages/react-server/src/global.ts @@ -1,4 +1,5 @@ import type { ViteDevServer } from "vite"; +import type { ModuleRunner } from "vite/module-runner"; import type { PluginStateManager } from "./plugin"; import type { CallServerCallback } from "./types/react"; @@ -7,7 +8,7 @@ import type { CallServerCallback } from "./types/react"; export const $__global: { dev: { server: ViteDevServer; - reactServer: ViteDevServer; + reactServerRunner: ModuleRunner; manager: PluginStateManager; }; callServer: CallServerCallback; diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index fe8994b4b..2a0770fcc 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -1,22 +1,19 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createDebug, tinyassert } from "@hiogawa/utils"; +import { createDebug, tinyassert, uniq } from "@hiogawa/utils"; import { type ConfigEnv, - type InlineConfig, type Plugin, - type PluginOption, type ResolvedConfig, type ViteDevServer, build, - createLogger, - createServer, - mergeConfig, + createBuilder, + createServerModuleRunner, + isCSSRequest, } from "vite"; import { crawlFrameworkPkgs } from "vitefu"; -import { CSS_LANGS_RE } from "../features/assets/css"; import { - serverAssertsPluginServer, + serverAssetsPluginServer, vitePluginServerAssets, } from "../features/assets/plugin"; import { SERVER_CSS_PROXY } from "../features/assets/shared"; @@ -46,10 +43,14 @@ import { $__global } from "../global"; import { ENTRY_BROWSER_WRAPPER, ENTRY_SERVER_WRAPPER, + applyPluginToServer, createVirtualPlugin, hashString, vitePluginSilenceDirectiveBuildWarning, + wrapClientPlugin, + wrapServerPlugin, } from "./utils"; +export { wrapClientPlugin, wrapServerPlugin } from "./utils"; const debug = createDebug("react-server:plugin"); @@ -120,7 +121,6 @@ const manager: PluginStateManager = (( ).__VITE_REACT_SERVER_MANAGER ??= new PluginStateManager()); export type ReactServerPluginOptions = { - plugins?: PluginOption[]; prerender?: PrerenderFn; entryBrowser?: string; entryServer?: string; @@ -139,138 +139,6 @@ export function vitePluginReactServer( const routeDir = options?.routeDir ?? "src/routes"; const outDir = options?.outDir ?? "dist"; - const reactServerViteConfig: InlineConfig = { - customLogger: createLogger(undefined, { - prefix: "[react-server]", - allowClearScreen: false, - }), - clearScreen: false, - configFile: false, - cacheDir: "./node_modules/.vite-rsc", - server: { - middlewareMode: true, - }, - 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|md|mdx)", - { 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]; - `; - }), - - 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 }); - `; - }), - - validateImportPlugin({ - "client-only": `'client-only' is included in server build`, - "server-only": true, - }), - - serverAssertsPluginServer({ manager }), - - serverDepsConfigPlugin(), - - { - name: "inherit-parent-config", - config(_config, _env) { - // this is only for `import.meta.env.xxx` replacement. - // users need to handle .env manually to have `process.env.xxx` - // avaiable on server runtime. - return { - envPrefix: manager.config.envPrefix, - }; - }, - }, - - { - 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, - }, - }, - }; - const rscParentPlugin: Plugin = { name: vitePluginReactServer.name, config(_config, env) { @@ -317,6 +185,27 @@ export function vitePluginReactServer( input: ENTRY_BROWSER_WRAPPER, }, }, + environments: { + rsc: { + // external and optimizeDeps are configured by `serverDepsConfigPlugin` + resolve: { + conditions: ["react-server"], + }, + build: { + outDir: path.join(outDir, "rsc"), + sourcemap: true, + ssr: true, + emitAssets: true, + manifest: true, + rollupOptions: { + input: { + index: ENTRY_SERVER_WRAPPER, + }, + output: OUTPUT_SERVER_JS_EXT, + }, + }, + }, + }, }; }, configResolved(config) { @@ -329,18 +218,19 @@ export function vitePluginReactServer( async buildStart(_options) { if (manager.configEnv.command === "serve") { tinyassert(manager.server); - const reactServer = await createServer(reactServerViteConfig); - reactServer.pluginContainer.buildStart({}); + const reactServerEnv = manager.server.environments["rsc"]; + tinyassert(reactServerEnv); + // custom environment's node runner doesn't have hmr currently + const reactServerRunner = createServerModuleRunner(reactServerEnv); $__global.dev = { server: manager.server, - reactServer: reactServer, + reactServerRunner, manager, }; } }, async buildEnd(_options) { if (manager.configEnv.command === "serve") { - await $__global.dev.reactServer.close(); delete ($__global as any).dev; } }, @@ -349,43 +239,55 @@ export function vitePluginReactServer( manager.parentIds.add(id); } }, - async handleHotUpdate(ctx) { - tinyassert(manager.server); - - // re-render RSC with custom event - if ( - $__global.dev.reactServer.moduleGraph.getModulesByFile(ctx.file) - ?.size && - ctx.modules.every((m) => m.id && manager.shouldReloadRsc(m.id)) - ) { - manager.server.hot.send({ - type: "custom", - event: "rsc:update", - data: { - file: ctx.file, - }, - }); + async hotUpdate(ctx) { + const isClientReference = ctx.modules.every( + (mod) => mod.id && manager.clientReferenceMap.has(mod.id), + ); + + if (this.environment.name === "rsc") { + // client reference id is also in react server module graph, + // but we skip RSC HMR for this case to avoid conflicting with Client HMR. + if (ctx.modules.length > 0 && !isClientReference) { + $__global.dev.server.environments.client.hot.send({ + type: "custom", + event: "rsc:update", + data: { + file: ctx.file, + }, + }); + } + } + + if (this.environment.name === "client") { + // css module is not self-accepting, so we filter out + // `?direct` module (used for SSR CSS) to avoid browser full reload. + // (see packages/react-server/src/features/assets/css.ts) + if (isCSSRequest(ctx.file)) { + return ctx.modules.filter((m) => !m.id?.includes("?direct")); + } - // Some rsc files are included in parent module graph - // due to postcss creating dependency from style.css to all source files. + // Server files can be included in client module graph + // due to postcss creating dependencies from style.css to all source files. // In this case, reload all importers (for css hmr), // and return empty modules to avoid full-reload - if (ctx.modules.every((m) => m.id && !manager.parentIds.has(m.id))) { - for (const m of ctx.modules) { - for (const imod of m.importers) { - await manager.server.reloadModule(imod); + const reactServerEnv = $__global.dev.server.environments["rsc"]!; + if ( + !isClientReference && + reactServerEnv.moduleGraph.getModulesByFile(ctx.file) + ) { + const importers = ctx.modules.flatMap((m) => [...m.importers]); + if ( + importers.length > 0 && + importers.every((m) => m.id && isCSSRequest(m.id)) + ) { + for (const m of importers) { + await this.environment.reloadModule(m); } + return []; } - return []; } } - // css module is not self-accepting, so we filter out - // `?direct` module (used for SSR CSS) to avoid browser full reload. - // (see packages/react-server/src/features/assets/css.ts) - if (CSS_LANGS_RE.test(ctx.file)) { - return ctx.modules.filter((m) => !m.id?.includes("?direct")); - } return; }, }; @@ -399,15 +301,12 @@ export function vitePluginReactServer( await createServerPackageJson(manager.outDir); console.log("▶▶▶ REACT SERVER BUILD (scan) [1/4]"); manager.buildType = "scan"; - await build( - mergeConfig(reactServerViteConfig, { - build: { write: false }, - } satisfies InlineConfig), - ); + const builder = await createBuilder(); + await builder.build(builder.environments["rsc"]!); console.log("▶▶▶ REACT SERVER BUILD (server) [2/4]"); manager.buildType = "server"; manager.clientReferenceMap.clear(); - await build(reactServerViteConfig); + await builder.build(builder.environments["rsc"]!); console.log("▶▶▶ REACT SERVER BUILD (browser) [3/4]"); manager.buildType = "browser"; } @@ -434,6 +333,93 @@ export function vitePluginReactServer( rscParentPlugin, buildOrchestrationPlugin, vitePluginSilenceDirectiveBuildWarning(), + + // + // react server + // + ...vitePluginServerUseServer({ + manager, + runtimePath: RUNTIME_SERVER_PATH, + }), + ...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|md|mdx)", + { 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]; + `; + }), + 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 }); + `; + }), + wrapServerPlugin( + validateImportPlugin({ + "client-only": `'client-only' is included in server build`, + "server-only": true, + }), + ), + ...serverAssetsPluginServer({ manager }), + serverDepsConfigPlugin(), + { + name: "patch-react-server-dom-webpack", + applyToEnvironment: applyPluginToServer, + 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, map: null }; + } + return; + }, + }, + + // + // react client + // + vitePluginClientUseServer({ manager, runtimePath: RUNTIME_BROWSER_PATH, @@ -445,11 +431,12 @@ export function vitePluginReactServer( ...(options?.prerender ? prerenderPlugin({ manager, prerender: options.prerender }) : []), - validateImportPlugin({ - "client-only": true, - "server-only": `'server-only' is included in client build`, - }), - + wrapClientPlugin( + validateImportPlugin({ + "client-only": true, + "server-only": `'server-only' is included in client build`, + }), + ), { name: "virtual:react-server-build", resolveId(source) { @@ -531,7 +518,11 @@ function validateImportPlugin(entries: Record): Plugin { function serverDepsConfigPlugin(): Plugin { return { name: serverDepsConfigPlugin.name, - async config(_config, env) { + async configEnvironment(name, _config, env) { + if (name !== "rsc") { + return; + } + // crawl packages with "react" or "next" in "peerDependencies" // see https://github.com/svitejs/vitefu/blob/d8d82fa121e3b2215ba437107093c77bde51b63b/src/index.js#L95-L101 const result = await crawlFrameworkPkgs({ @@ -544,27 +535,24 @@ function serverDepsConfigPlugin(): Plugin { }); return { - ssr: { - resolve: { - conditions: ["react-server"], - }, - noExternal: [ + resolve: { + noExternal: uniq([ "react", "react-dom", "react-server-dom-webpack", "server-only", "client-only", ...result.ssr.noExternal, + ]).sort(), + }, + // pre-bundle cjs deps + optimizeDeps: { + include: [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "react-server-dom-webpack/server.edge", ], - // pre-bundle cjs deps - optimizeDeps: { - include: [ - "react", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "react-server-dom-webpack/server.edge", - ], - }, }, }; }, diff --git a/packages/react-server/src/plugin/utils.tsx b/packages/react-server/src/plugin/utils.tsx index 708ba11b0..b850ff750 100644 --- a/packages/react-server/src/plugin/utils.tsx +++ b/packages/react-server/src/plugin/utils.tsx @@ -1,6 +1,27 @@ import nodeCrypto from "node:crypto"; import type { Plugin, ViteDevServer } from "vite"; +export const applyPluginToServer: Plugin["applyToEnvironment"] = (env) => + env.name === "rsc"; +export const applyPluginToClient: Plugin["applyToEnvironment"] = (env) => + env.name !== "rsc"; + +export function wrapServerPlugin(p: T): T { + const wrap = (p: Plugin): Plugin => ({ + ...p, + applyToEnvironment: applyPluginToServer, + }); + return Array.isArray(p) ? p.map((p) => wrap(p)) : (wrap(p) as any); +} + +export function wrapClientPlugin(p: T): T { + const wrap = (p: Plugin): Plugin => ({ + ...p, + applyToEnvironment: applyPluginToClient, + }); + return Array.isArray(p) ? p.map((p) => wrap(p)) : (wrap(p) as any); +} + export function invalidateModule(server: ViteDevServer, id: string) { const mod = server.moduleGraph.getModuleById(id); if (mod) { diff --git a/packages/react-server/vitest.config.e2e.ts b/packages/react-server/vitest.config.e2e.ts index 1a27eb9b7..b26c7829d 100644 --- a/packages/react-server/vitest.config.e2e.ts +++ b/packages/react-server/vitest.config.e2e.ts @@ -10,5 +10,6 @@ export default defineConfig({ dir: "e2e", pool: "forks", fileParallelism: false, + watch: false, }, });