UNPKG

@edgeone/react-router

Version:

EdgeOne adapter plugin for React Router

674 lines (663 loc) โ€ข 26.3 kB
/** * EdgeOne Adapter Plugin for React Router * * Automatically performs post-processing after React Router build completion, * converting build artifacts to a format deployable on the EdgeOne platform */ import * as esbuild from "esbuild"; import fs from "fs/promises"; import path from "path"; /** * Format file size */ // function formatSize(bytes: number): string { // if (bytes === 0) return "0 B"; // const k = 1024; // const sizes = ["B", "KB", "MB", "GB"]; // const i = Math.floor(Math.log(bytes) / Math.log(k)); // return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; // } /** * EdgeOne adapter plugin */ export function edgeoneAdapter(options = {}) { const { verbose = false } = options; // Fixed configuration const outputDir = ".edgeone"; const cleanOutput = true; let projectRoot; let isSSR = false; let prerenderRoutes = []; let prerenderEnabled = false; // Mark whether prerender is true let isBuildingForSSR = false; // Track if current build is for SSR const log = (message, ...args) => { console.log(`[EdgeOne Adapter] ${message}`, ...args); }; const logVerbose = (message, ...args) => { if (verbose) { console.log(`[EdgeOne Adapter] ${message}`, ...args); } }; return { name: "vite-plugin-edgeone-adapter", apply: "build", enforce: "post", configResolved(config) { projectRoot = config.root; // Detect if this is SSR build by checking build.ssr config isBuildingForSSR = config.build.ssr !== false && config.build.ssr !== undefined; logVerbose("Project root directory:", projectRoot); logVerbose("Current build type:", isBuildingForSSR ? "SSR" : "Client"); // Extract React Router config from Vite config extractReactRouterConfig(config); }, async writeBundle() { // Skip execution if: // 1. SSR is enabled in config AND // 2. Current build is NOT for SSR (i.e., it's client build) // This ensures we only run once after the server build completes if (isSSR && !isBuildingForSSR) { logVerbose("Skipping adapter execution (waiting for SSR build to complete)"); return; } // Add a small delay to ensure all files are written await new Promise((resolve) => setTimeout(resolve, 100)); try { // log("๐Ÿš€ Starting EdgeOne adapter processing...\n"); // 1. Wait for React Router build to complete await waitForBuildComplete(); // 2. Clean output directory if (cleanOutput) { await cleanOutputDirectory(); } // 3. Copy static assets await copyStaticAssets(); // 4. Bundle server code (SSR mode only) if (isSSR) { await bundleServerCode(); } // 5. Generate meta.json (SSR mode only) if (isSSR) { await generateMetaJson(); } // log("\nโœจ EdgeOne adapter processing completed!"); // showUsageInstructions(); } catch (error) { console.error("\nโŒ EdgeOne adapter processing failed:", error); throw error; } }, }; /** * Wait for React Router build to complete * Check if required build files exist, retry if not found */ async function waitForBuildComplete() { const maxRetries = 15; const retryDelay = 300; // ms // Always check for client build const clientBuildPaths = [ path.join(projectRoot, "build/client"), path.join(projectRoot, "build-csr/client"), path.join(projectRoot, "build-ssr/client"), ]; // Check if any client build exists let clientBuildFound = false; for (const clientPath of clientBuildPaths) { try { await fs.access(clientPath); clientBuildFound = true; logVerbose(` โœ… Client build found: ${clientPath}`); break; } catch { continue; } } if (!clientBuildFound) { throw new Error("Client build directory not found. Please ensure React Router build completed successfully."); } // If SSR mode, check for server build (should exist since we run after SSR build) if (isSSR) { const serverBuildPath = path.join(projectRoot, "build/server/index.js"); let serverBuildFound = false; // Since we're running after SSR build, the file should exist // But add a retry mechanism in case of file system delays for (let i = 0; i < maxRetries; i++) { try { await fs.access(serverBuildPath); serverBuildFound = true; logVerbose(` โœ… Server build found: ${serverBuildPath}`); break; } catch { if (i < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); } } } if (!serverBuildFound) { throw new Error(`Server build not found at ${serverBuildPath}. Please ensure React Router SSR build completed successfully.`); } } logVerbose(" โœ… Build files verification completed"); } /** * Extract React Router configuration from Vite config * This method reads from the already resolved Vite config object * instead of trying to import the config file directly */ function extractReactRouterConfig(config) { try { // React Router plugin should inject config into Vite config // Look for reactRouter config in various possible locations const reactRouterConfig = config.reactRouter || // Direct config config.__reactRouterConfig || // Private field (config.plugins?.find((p) => p?.name?.includes('react-router'))?.config); // Plugin config if (reactRouterConfig) { // Read SSR configuration isSSR = reactRouterConfig.ssr !== false; // Read prerender configuration if (reactRouterConfig.prerender) { // Store for later async processing const prerenderConfig = reactRouterConfig.prerender; // Handle boolean if (typeof prerenderConfig === 'boolean') { prerenderEnabled = prerenderConfig; logVerbose("Prerender mode: All routes"); } // Handle array else if (Array.isArray(prerenderConfig)) { prerenderRoutes = prerenderConfig; logVerbose(`Prerender routes: ${prerenderRoutes.join(", ")}`); } // Handle function - will be resolved later when needed else if (typeof prerenderConfig === 'function') { logVerbose("Prerender function detected, will resolve later"); } } logVerbose(`React Router config loaded from Vite config (SSR: ${isSSR})`); } else { // Fallback: try to detect from build configuration // If build.ssr is configured, assume SSR is enabled if (config.build?.ssr) { isSSR = true; logVerbose("SSR detected from build.ssr configuration"); } else { logVerbose("No React Router config found in Vite config, using defaults"); } } } catch (error) { logVerbose("Failed to extract React Router config:", error); // Use defaults: isSSR remains false } } /** * Clean output directory */ async function cleanOutputDirectory() { const outputPath = path.join(projectRoot, outputDir); const assetsPath = path.join(outputPath, "assets"); const serverHandlerPath = path.join(outputPath, "server-handler"); const metaPath = path.join(outputPath, "meta.json"); try { // Clean assets and server-handler directories, as well as meta.json file await fs.rm(assetsPath, { recursive: true, force: true }); await fs.rm(serverHandlerPath, { recursive: true, force: true }); await fs.rm(metaPath, { force: true }); logVerbose("๐Ÿงน Cleaned directories: assets, server-handler, meta.json"); } catch (error) { // Directory might not exist, ignore error } // Ensure output directory exists await fs.mkdir(outputPath, { recursive: true }); logVerbose("๐Ÿ“ Ensured output directory exists:", outputDir); logVerbose(""); } /** * Copy static assets */ async function copyStaticAssets() { // log("๐Ÿ“ฆ Copying static assets..."); const targetPath = path.join(projectRoot, outputDir, "assets"); // Try multiple possible build artifact paths const possibleSourcePaths = [ path.join(projectRoot, "build/client"), path.join(projectRoot, "build-csr/client"), path.join(projectRoot, "build-ssr/client"), ]; let sourcePath = null; // Find existing build artifact directory for (const possiblePath of possibleSourcePaths) { try { await fs.access(possiblePath); sourcePath = possiblePath; logVerbose(` Found build artifacts: ${possiblePath}`); break; } catch (error) { // Continue trying next path continue; } } if (!sourcePath) { throw new Error(`Build artifact directory not found, please run build command first`); } try { // Recursively copy directory await fs.cp(sourcePath, targetPath, { recursive: true }); // Count files // const fileCount = await countFiles(targetPath); // log(` โœ… Copied ${fileCount} files to ${outputDir}/assets`); } catch (error) { throw new Error(`Failed to copy static assets: ${error}`); } } /** * Bundle server code */ async function bundleServerCode() { // log("๐Ÿ”จ Bundling server code..."); const serverEntryPath = path.join(projectRoot, "build/server/index.js"); const outputPath = path.join(projectRoot, outputDir, "server-handler"); const outputFile = path.join(outputPath, "handler.js"); try { // Check if entry file exists await fs.access(serverEntryPath); } catch (error) { log(" โš ๏ธ build/server/index.js does not exist, skipping server bundling"); log(" Hint: Ensure React Router is configured for SSR mode and built correctly"); log(""); return; } try { // Create output directory await fs.mkdir(outputPath, { recursive: true }); // Create server wrapper file const wrapperPath = await createServerWrapper(serverEntryPath); // Bundle using esbuild await esbuild.build({ entryPoints: [wrapperPath], bundle: true, platform: "node", target: "node18", format: "esm", outfile: outputFile, minify: false, treeShaking: true, external: ["node:*"], metafile: true, logLevel: "warning", absWorkingDir: projectRoot, banner: { js: `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const __filename = new URL('', import.meta.url).pathname; const __dirname = new URL('.', import.meta.url).pathname;`, }, }); // Clean up temporary file await fs.unlink(wrapperPath); // Show build statistics // const outputInfo = // result.metafile!.outputs[Object.keys(result.metafile!.outputs)[0]]; // log(` โœ… Server code bundled`); // log(` File: ${outputDir}/server-handler/index.mjs`); // log(` Size: ${formatSize(outputInfo.bytes)}`); // log(""); } catch (error) { throw new Error(`Failed to bundle server code: ${error}`); } } /** * Create server wrapper file */ async function createServerWrapper(serverBuildPath) { logVerbose("๐Ÿ“ Creating server wrapper file..."); // Read original build/server/index.js const serverBuildContent = await fs.readFile(serverBuildPath, "utf-8"); const wrapperContent = `// ========== React Router Server Build ========== ${serverBuildContent} // ========== HTTP Server Wrapper ========== import { createRequestHandler } from "react-router"; // Get exported build configuration const buildConfig = { assets: serverManifest, assetsBuildDirectory, basename, entry, future, isSpaMode, publicPath, routes, routeDiscovery, ssr }; // Create React Router request listener (accepts Web Request) const requestHandler = createRequestHandler(buildConfig); /** * Convert Node.js IncomingMessage to Web API Request */ function nodeRequestToWebRequest(nodeReq) { // Build full URL const protocol = nodeReq.connection.encrypted ? 'https' : 'http'; const host = nodeReq.headers.host || 'localhost'; const url = \`\${protocol}://\${host}\${nodeReq.url}\`; // Convert headers const headers = new Headers(); for (const [key, value] of Object.entries(nodeReq.headers)) { if (value) { if (Array.isArray(value)) { value.forEach(v => headers.append(key, v)); } else { headers.set(key, value); } } } // Build request init options const init = { method: nodeReq.method, headers: headers, }; // Add body for non-GET/HEAD requests if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') { init.body = nodeReq; } return new Request(url, init); } /** * Node.js request handler wrapper * Converts first argument (Node.js req) to Web Request, other arguments are passed through */ async function nodeRequestHandler(nodeReq, ...args) { // Convert Node.js request to Web Request const webRequest = nodeRequestToWebRequest(nodeReq); // Call React Router request handler with Web Request and other arguments return requestHandler(webRequest, ...args); } // Export Node.js request handler as default export default nodeRequestHandler; `; const tempPath = path.join(projectRoot, "server-wrapper.temp.js"); await fs.writeFile(tempPath, wrapperContent); logVerbose(" โœ… Server wrapper file created"); return tempPath; } /** * Generate meta.json */ async function generateMetaJson() { // log("๐Ÿ“„ Generating meta.json..."); try { // Read route configuration (get serverManifest from build artifacts) const manifest = await parseRoutes(); // Generate route list (intelligently determine rendering mode for each route) const frameworkRoutes = generateRouteList(manifest); // log("frameworkRoutes", frameworkRoutes); // Build meta configuration const metaConfig = { conf: { headers: [], redirects: [], rewrites: [], caches: [], has404: false, ssr404: true, }, has404: false, frameworkRoutes, }; // Write file to two locations const metaContent = JSON.stringify(metaConfig, null, 2); // 1. Write to server-handler directory const serverHandlerDir = path.join(projectRoot, outputDir, "server-handler"); await fs.mkdir(serverHandlerDir, { recursive: true }); const serverMetaPath = path.join(serverHandlerDir, "meta.json"); await fs.writeFile(serverMetaPath, metaContent); // 2. Write to root directory const rootMetaPath = path.join(projectRoot, outputDir, "meta.json"); await fs.writeFile(rootMetaPath, metaContent); // log(` โœ… meta.json generated`); // log(` Route count: ${frameworkRoutes.length}`); // log(""); } catch (error) { throw new Error(`Failed to generate meta.json: ${error}`); } } /** * Parse route configuration - unified reading from client manifest file * Both SSR and CSR modes generate client manifest files * This allows getting complete meta information for each route (hasLoader, hasClientLoader, etc.) */ async function parseRoutes() { logVerbose(" ๐Ÿ” Looking for manifest file..."); // Possible build artifact paths (sorted by priority) const possibleBuildPaths = [path.join(projectRoot, "build/client")]; for (const buildPath of possibleBuildPaths) { try { // Check if directory exists await fs.access(buildPath); // Search for manifest files in current directory and assets subdirectory const searchPaths = [buildPath, path.join(buildPath, "assets")]; for (const searchPath of searchPaths) { try { const files = await fs.readdir(searchPath); // Look for manifest-*.js files const manifestFile = files.find((f) => f.startsWith("manifest-") && f.endsWith(".js")); if (manifestFile) { const manifestPath = path.join(searchPath, manifestFile); const manifest = await parseManifestFile(manifestPath); if (manifest) { logVerbose(` โœ… Successfully read manifest: ${manifestPath}`); return manifest; } } } catch (error) { // Continue trying next search path continue; } } } catch (error) { // Continue trying next build path continue; } } log(" โš ๏ธ Manifest file not found, will use default route configuration"); return null; } /** * Parse manifest file content */ async function parseManifestFile(manifestPath) { try { const manifestContent = await fs.readFile(manifestPath, "utf-8"); // Parse manifest content (format: window.__reactRouterManifest={...}) const match = manifestContent.match(/window\.__reactRouterManifest\s*=\s*({.*?});?\s*$/s); if (match) { const manifestData = JSON.parse(match[1]); // Validate manifest data structure if (manifestData.routes && manifestData.version) { return { routes: manifestData.routes, version: manifestData.version, }; } } logVerbose(` โš ๏ธ Manifest file format incorrect: ${manifestPath}`); return null; } catch (error) { logVerbose(` โš ๏ธ Failed to parse manifest file: ${error}`); return null; } } /** * Generate route list - intelligently determine rendering mode for each route based on serverManifest */ function generateRouteList(manifest) { const routeList = []; if (!manifest) { log(" โš ๏ธ Unable to get route manifest, using default configuration"); return routeList; } // Iterate through all routes for (const [routeId, routeInfo] of Object.entries(manifest.routes)) { // Skip root route if (routeId === "root") continue; // Calculate route path const routePath = calculateRoutePath(routeInfo); // Determine if it's a prerender route // 1. If prerender: true, all routes are prerendered // 2. Otherwise check if route is in prerender list const isPrerender = prerenderEnabled || prerenderRoutes.includes(routePath); // Determine rendering mode const renderMode = determineRenderMode(routeInfo, isPrerender); logVerbose(` Route: ${routePath} -> ${renderMode}`); // Add main route routeList.push({ path: routePath, isStatic: !isSSR, // All routes are static in CSR mode srcRoute: routePath, }); // SSR routes need .data routes (for getting data during client navigation) if (renderMode === "ssr" && routePath && routePath.trim() !== "") { // Root route's .data route should be /_root.data const dataPath = routePath === "/" ? "/_root.data" : `${routePath}.data`; routeList.push({ path: dataPath, isStatic: false, }); } } // Add __manifest route in SSR mode if (isSSR) { routeList.push({ path: "/__manifest", isStatic: false, }); } return routeList; } /** * Determine route rendering mode * @returns "ssr" | "csr" | "static" */ function determineRenderMode(routeInfo, isPrerender) { // 1. Prerender route -> static if (isPrerender) { return "static"; } // 2. Only clientLoader, no loader -> CSR if (routeInfo.hasClientLoader && !routeInfo.hasLoader) { return "csr"; } // 3. Has loader (regardless of clientLoader) -> SSR if (routeInfo.hasLoader) { return "ssr"; } // 4. Neither loader nor clientLoader, default to static return "static"; } /** * Calculate complete route path */ function calculateRoutePath(routeInfo) { // Handle index route if (routeInfo.index) { return "/"; } // Handle regular route let routePath; if (routeInfo.path) { routePath = routeInfo.path.startsWith("/") ? routeInfo.path : `/${routeInfo.path}`; } else { // Infer path from id (fallback) const pathSegment = routeInfo.id.replace(/^routes\//, ""); if (pathSegment === "home" || pathSegment === "index") { return "/"; } routePath = `/${pathSegment.replace(/Page$/, "").toLowerCase()}`; } // Convert paths containing * to regex format (without parentheses) if (routePath.includes("*")) { // Escape special regex characters except * routePath = routePath.replace(/[.+?^${}|[\]\\]/g, "\\$&"); // Handle trailing /* - can match empty or any path // /docs/* should match /docs, /docs/, /docs/anything // Using /.* with optional slash: make the / before * optional using /?.* if (routePath.endsWith("/*")) { routePath = routePath.slice(0, -2) + "/?.*"; } else { // Replace other * with .* routePath = routePath.replace(/\*/g, ".*"); } } return routePath; } /** * Count files */ // async function countFiles(dir: string): Promise<number> { // let count = 0; // try { // const entries = await fs.readdir(dir, { withFileTypes: true }); // for (const entry of entries) { // if (entry.isDirectory()) { // count += await countFiles(path.join(dir, entry.name)); // } else { // count++; // } // } // } catch (error) { // // Ignore errors // } // return count; // } /** * Show usage instructions */ // function showUsageInstructions() { // log("\n๐Ÿ“– Usage Instructions:"); // log(` Output directory: ${outputDir}/`); // log(` โ”œโ”€โ”€ assets/ # Static assets`); // if (isSSR) { // log(` โ”œโ”€โ”€ server-handler/ # Server code`); // log(` โ”‚ โ””โ”€โ”€ index.mjs`); // } // log(` โ””โ”€โ”€ meta.json # Route metadata`); // log(""); // if (isSSR) { // log(" Start server:"); // log(` node ${outputDir}/server-handler/index.mjs`); // log(""); // } // log(" Deploy to EdgeOne:"); // log(` 1. Upload ${outputDir}/ directory to EdgeOne platform`); // log(` 2. Configure static asset path as ${outputDir}/assets`); // if (isSSR) { // log( // ` 3. Configure server entry as ${outputDir}/server-handler/index.mjs` // ); // } // log(""); // } } export default edgeoneAdapter; //# sourceMappingURL=index.js.map