@edgeone/react-router
Version:
EdgeOne adapter plugin for React Router
674 lines (663 loc) โข 26.3 kB
JavaScript
/**
* 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