UNPKG

vite-plugin-react-server

Version:
321 lines (299 loc) 11 kB
import { type Manifest, type Plugin, type ResolvedConfig } from "vite"; import type { CheckFilesExistReturn, ResolvedUserConfig, ResolvedUserOptions, StreamPluginOptions, } from "../types.js"; import { resolveOptions } from "../config/resolveOptions.js"; import { resolveUserConfig } from "../config/resolveUserConfig.js"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { getBundleManifest } from "../helpers/getBundleManifest.js"; import { checkFilesExist } from "../checkFilesExist.js"; import { resolvePages } from "../config/resolvePages.js"; import { createInputNormalizer } from "../helpers/inputNormalizer.js"; import { createWorker } from "../worker/createWorker.js"; import type { Worker } from "node:worker_threads"; import { DEFAULT_CONFIG } from "../config/defaults.js"; import type { RscRenderMessage, RscWorkerMessage, RscWorkerResponse, } from "../worker/types.js"; import { MIME_TYPES } from "../config/mimeTypes.js"; let userOptions: ResolvedUserOptions; let userConfig: ResolvedUserConfig; let clientManifest: Manifest = {}; let resolvedConfig: ResolvedConfig; let root: string; let loader: (id: string) => Promise<Record<string, any>> = (id: string) => import(id); let worker: Worker; let files: CheckFilesExistReturn; export function reactClientPlugin(options: StreamPluginOptions): Plugin { const resolvedOptions = resolveOptions(options, true); if (resolvedOptions.type === "error") { throw resolvedOptions.error; } userOptions = resolvedOptions.userOptions; root = userOptions.projectRoot; return { name: "vite:react-client", async config(config, configEnv) { if ( typeof config.root === "string" && config.root !== root && config.root !== process.cwd() && config.root !== "" ) { root = config.root; console.log("[vite:react-client] Root updated:", root); } if (configEnv.command === "serve" && !configEnv.isPreview && !worker) { worker = await createWorker({ projectRoot: root, workerPath: userOptions.rscWorkerPath, reverseCondition: true, }); } const pages = await resolvePages(userOptions.build.pages); if (pages.type === "error") { throw pages.error; } if (pages.pages.length > 0) { files = await checkFilesExist(pages.pages, userOptions, root); } else { files = { pageMap: new Map(), propsMap: new Map(), propsSet: new Set(), pageSet: new Set(), urlMap: new Map(), errors: [], }; } const resolvedConfig = resolveUserConfig({ isClient: true, config, configEnv, userOptions, files, }); if (resolvedConfig.type === "error") { throw resolvedConfig.error; } userConfig = resolvedConfig.userConfig; return userConfig; }, configResolved(config) { resolvedConfig = config; }, async generateBundle(_options, bundle) { // Create manifest entries for each chunk clientManifest = getBundleManifest({ pluginContext: this, bundle, moduleBase: userOptions.moduleBase, preserveModulesRoot: userOptions.build.preserveModulesRoot, }); // Write manifest immediately after generation const manifestPath = join( root, resolvedConfig.environments["client"].build.outDir as string, resolvedConfig.environments["client"].build.manifest as string ); await mkdir(dirname(manifestPath), { recursive: true }); return await writeFile( manifestPath, JSON.stringify(clientManifest, null, 2) ); }, async configurePreviewServer(server) { if (root !== server.config.root) { root = server.config.root; } if (typeof loader !== "function") { loader = (id: string) => import(id); } const normalize = createInputNormalizer({ root, removeExtension: false, preserveModulesRoot: userOptions.build.preserveModulesRoot ? userOptions.moduleBase : undefined, }); server.middlewares.use(async (req, res, next) => { const [key, value] = normalize(req.url); const fileRoot = key.startsWith("node_modules") ? root : join(root, userOptions.build.outDir, userOptions.build.static); try { const filePath = join(fileRoot, value); const stats = await stat(filePath); if (stats.isFile()) { const ext = value.slice(value.lastIndexOf('.')); const contentType = MIME_TYPES[ext] || 'application/octet-stream'; res.setHeader('Content-Type', contentType); const content = await readFile(filePath); res.end(content); return; } next(); } catch (error) { console.log("Error serving static file:", error); next(); } }); }, // setup dev server async configureServer(server) { if (typeof loader !== "function") { loader = server.ssrLoadModule; } if (!worker) { worker = await createWorker({ projectRoot: root, workerPath: userOptions.rscWorkerPath, condition: "react-client", }); } const normalize = createInputNormalizer({ root, removeExtension: false, preserveModulesRoot: userOptions.build.preserveModulesRoot ? userOptions.moduleBase : undefined, }); server.middlewares.use(async (req, res, next) => { if (!req.url) { next(); return; } if ( req.url.endsWith(".rsc") || req.headers.accept?.includes("text/x-component") ) { try { const path = req.url?.includes("index.rsc") ? req.url.replace("index.rsc", "") : req.url?.replace(".rsc", ""); let [key, value] = normalize(path); let pageImport = DEFAULT_CONFIG.PAGE as string; let propsImport = DEFAULT_CONFIG.PROPS as string; // PAGE // no trailing slash const pathNoTrailing = path?.replace(/\/$/, ''); if (files.urlMap.has(req.url)) { pageImport = files.urlMap.get(req.url)!.page; propsImport = files.urlMap.get(req.url)!.props; } else if (files.urlMap.has(pathNoTrailing)) { pageImport = files.urlMap.get(pathNoTrailing)!.page; propsImport = files.urlMap.get(pathNoTrailing)!.props; } else if (files.urlMap.has(path)) { pageImport = files.urlMap.get(path)!.page; propsImport = files.urlMap.get(path)!.props; } else if (files.urlMap.has(value)) { pageImport = files.urlMap.get(value)!.page; propsImport = files.urlMap.get(value)!.props; } else if (files.urlMap.has(key)) { pageImport = files.urlMap.get(key)!.page; propsImport = files.urlMap.get(key)!.props; } else { console.warn(`Page/props import not found for any of the following (in order of priority): ${[req.url, pathNoTrailing, path, value, key].filter(Boolean).join(', ')} available pages:${Array.from(files.urlMap.keys()).join(', ')}`); } // Set headers early res.setHeader("Content-Type", "text/x-component"); res.setHeader("Transfer-Encoding", "chunked"); res.setHeader("Connection", "keep-alive"); let hasError = false; const timeout = setTimeout(() => { if (!hasError) { hasError = true; res.statusCode = 500; res.end("RSC render timeout"); } }, 5000); const messageHandler = ( message: RscWorkerMessage | RscWorkerResponse ) => { try { switch (message.type) { case "RSC_CHUNK": // Write chunk directly to response if (!hasError) { res.write(message.chunk); } break; case "RSC_END": clearTimeout(timeout); if (!hasError) { res.end(); } worker.off("message", messageHandler); break; case "ERROR": clearTimeout(timeout); if (!hasError) { hasError = true; res.statusCode = 500; res.end(message.error); } worker.off("message", messageHandler); break; } } catch (error) { clearTimeout(timeout); if (!hasError) { hasError = true; res.statusCode = 500; res.end( error instanceof Error ? error.message : String(error) ); } worker.off("message", messageHandler); } }; worker.on("message", messageHandler); worker.once("error", (error) => { clearTimeout(timeout); if (!hasError) { hasError = true; res.statusCode = 500; res.end(error instanceof Error ? error.message : String(error)); } worker.off("message", messageHandler); }); worker.postMessage({ type: "RSC_RENDER", id: value, pageImport, propsImport, url: req.url ?? "/", pageExportName: userOptions.pageExportName ?? DEFAULT_CONFIG.PAGE_EXPORT_NAME, propsExportName: userOptions.propsExportName ?? DEFAULT_CONFIG.PROPS_EXPORT_NAME, outDir: userOptions.build.outDir, projectRoot: root, moduleRootPath: userOptions.build.preserveModulesRoot === true ? userOptions.moduleBase : "", moduleBaseURL: userOptions.moduleBaseURL, moduleBasePath: userOptions.moduleBasePath, moduleBase: userOptions.moduleBase, pipableStreamOptions: userOptions.pipableStreamOptions, cssFiles: [] } satisfies RscRenderMessage); } catch (error) { res.statusCode = 500; res.end(error instanceof Error ? error.message : String(error)); } } else { next(); } }); }, }; }