UNPKG

rsbuild-plugin-react-router

Version:
708 lines (657 loc) 19.6 kB
import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import * as Effect from 'effect/Effect'; import type { RsbuildPluginAPI } from '@rsbuild/core'; import { createRequestHandler, matchRoutes, type ServerBuild, } from 'react-router'; import { dirname, relative, resolve } from 'pathe'; import { PLUGIN_NAME } from './constants.js'; import { getBuildManifest } from './build-manifest.js'; import { generateReactRouterManifestForDev, getReactRouterManifestForDev, type ReactRouterManifestStats, type RouteManifestModuleExports, } from './manifest.js'; import { createPrerenderRoutes, getPrerenderConcurrency, getSsrFalsePrerenderExportErrors, normalizePrerenderMatchPath, } from './prerender.js'; import type { Config, ResolvedReactRouterConfig, } from './react-router-config.js'; import { resolveServerBuildModule } from './server-utils.js'; import type { PluginOptions, Route } from './types.js'; import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; type ReactRouterManifest = Awaited< ReturnType<typeof getReactRouterManifestForDev> >; type BuildRouteModule = { loader?: unknown; default?: unknown; ErrorBoundary?: unknown; }; type PrerenderServerBuild = ServerBuild & { routes: Record<string, { id?: string; module?: BuildRouteModule }>; assets?: { routes?: Record<string, { hasLoader?: boolean }>; }; prerender?: string[]; }; type PrerenderBuildApi = Pick< RsbuildPluginAPI, 'logger' | 'getNormalizedConfig' >; type RunReactRouterPrerenderBuildOptions = { api: PrerenderBuildApi; hasWebEnvironment: boolean; buildDirectory: string; serverBuildFile?: string; ssr: boolean; isPrerenderEnabled: boolean; prerenderConfig: Config['prerender']; prerenderPaths: string[]; basename: string; future: ResolvedReactRouterConfig['future']; routes: Record<string, Route>; latestBrowserManifest: ReactRouterManifest | null; latestBrowserManifestModuleExports: RouteManifestModuleExports; clientStats: ReactRouterManifestStats | undefined; pluginOptions: PluginOptions; appDirectory: string; assetPrefix: string; routeChunkOptions: Parameters<typeof getReactRouterManifestForDev>[5]; buildManifest: Awaited<ReturnType<typeof getBuildManifest>>; resolvedConfigWithRoutes: ResolvedReactRouterConfig; buildEnd: Config['buildEnd']; }; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); const getServerBuildPath = async ({ buildDirectory, serverBuildFile, }: { buildDirectory: string; serverBuildFile?: string; }): Promise<{ serverBuildDir: string; serverBuildPath: string }> => { const serverBuildDir = resolve(buildDirectory, 'server'); const defaultServerBuildFile = 'static/js/app.js'; const configuredServerBuildFile = serverBuildFile || 'index.js'; const configuredServerBuildPath = resolve( serverBuildDir, configuredServerBuildFile ); const defaultServerBuildPath = resolve( serverBuildDir, defaultServerBuildFile ); if ( configuredServerBuildFile !== defaultServerBuildFile && existsSync(defaultServerBuildPath) && !existsSync(configuredServerBuildPath) ) { await mkdir(dirname(configuredServerBuildPath), { recursive: true }); await fsExtra.copy(defaultServerBuildPath, configuredServerBuildPath); } return { serverBuildDir, serverBuildPath: existsSync(configuredServerBuildPath) ? configuredServerBuildPath : defaultServerBuildPath, }; }; const createDataRequestPath = ( prerenderPath: string, trailingSlashAwareDataRequests: boolean ): string => { if (trailingSlashAwareDataRequests) { return prerenderPath.endsWith('/') ? `${prerenderPath}_.data` : `${prerenderPath}.data`; } return prerenderPath === '/' ? '/_root.data' : `${prerenderPath.replace(/\/$/, '')}.data`; }; export const createBuildRequestEffect = <T>( input: string | URL, init: RequestInit | undefined, handle: (request: Request) => Promise<T> ): Effect.Effect<T, Error, never> => Effect.acquireUseRelease( Effect.sync(() => new AbortController()), controller => tryPluginPromise(() => handle( new Request(input, { ...init, signal: controller.signal, }) ) ), controller => Effect.sync(() => { controller.abort(); }) ); export const withBuildRequest = <T>( input: string | URL, init: RequestInit | undefined, handle: (request: Request) => Promise<T> ): Promise<T> => runPluginEffect(createBuildRequestEffect(input, init, handle)); const prerenderData = async ({ handler, prerenderPath, onlyRoutes, clientBuildDir, basename, trailingSlashAwareDataRequests, api, requestInit, }: { handler: (request: Request) => Promise<Response>; prerenderPath: string; onlyRoutes: string[] | null; clientBuildDir: string; basename: string; trailingSlashAwareDataRequests: boolean; api: PrerenderBuildApi; requestInit?: RequestInit; }): Promise<string> => { const dataOutputPath = createDataRequestPath( prerenderPath, trailingSlashAwareDataRequests ); // The handler always serves the root route's data at /_root.data, even when // trailing-slash-aware naming writes the root output to /_.data. const dataRequestPath = trailingSlashAwareDataRequests && prerenderPath === '/' ? '/_root.data' : dataOutputPath; const normalizedPath = `${basename}${dataRequestPath}`.replace(/\/\/+/g, '/'); const outputNormalizedPath = dataOutputPath === dataRequestPath ? normalizedPath : `${basename}${dataOutputPath}`.replace(/\/\/+/g, '/'); const url = new URL(`http://localhost${normalizedPath}`); if (onlyRoutes?.length) { url.searchParams.set('_routes', onlyRoutes.join(',')); } return withBuildRequest(url, requestInit, async request => { const response = await handler(request); const data = await response.text(); if (response.status !== 200 && response.status !== 202) { throw new Error( `Prerender (data): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` path.\n` + `${normalizedPath}` ); } const outputPath = resolve( clientBuildDir, ...outputNormalizedPath.split('/') ); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, data); api.logger.info( `Prerender (data): ${prerenderPath} -> ${relative( process.cwd(), outputPath )}` ); return data; }); }; const prerenderRoute = async ({ handler, prerenderPath, clientBuildDir, basename, api, requestInit, }: { handler: (request: Request) => Promise<Response>; prerenderPath: string; clientBuildDir: string; basename: string; api: PrerenderBuildApi; requestInit?: RequestInit; }): Promise<void> => { const normalizedPath = `${basename}${prerenderPath}/`.replace(/\/\/+/g, '/'); await withBuildRequest( `http://localhost${normalizedPath}`, requestInit, async request => { const response = await handler(request); let html = await response.text(); if (redirectStatusCodes.has(response.status)) { const location = response.headers.get('Location'); const delay = response.status === 302 ? 2 : 0; html = `<!doctype html> <head> <title>Redirecting to: ${location}</title> <meta http-equiv="refresh" content="${delay};url=${location}"> <meta name="robots" content="noindex"> </head> <body> \t<a href="${location}"> Redirecting from <code>${normalizedPath}</code> to <code>${location}</code> </a> </body> </html>`; } else if (response.status !== 200) { throw new Error( `Prerender (html): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + html ); } const outputPath = resolve( clientBuildDir, ...normalizedPath.split('/'), 'index.html' ); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, html); api.logger.info( `Prerender (html): ${prerenderPath} -> ${relative( process.cwd(), outputPath )}` ); } ); }; const prerenderResourceRoute = async ({ handler, prerenderPath, clientBuildDir, basename, api, requestInit, }: { handler: (request: Request) => Promise<Response>; prerenderPath: string; clientBuildDir: string; basename: string; api: PrerenderBuildApi; requestInit?: RequestInit; }): Promise<void> => { const normalizedPath = `${basename}${prerenderPath}/` .replace(/\/\/+/g, '/') .replace(/\/$/g, ''); await withBuildRequest( `http://localhost${normalizedPath}`, requestInit, async request => { const response = await handler(request); const content = Buffer.from(await response.arrayBuffer()); if (response.status !== 200) { throw new Error( `Prerender (resource): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + content.toString('utf8') ); } const outputPath = resolve(clientBuildDir, ...normalizedPath.split('/')); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, content); api.logger.info( `Prerender (resource): ${prerenderPath} -> ${relative( process.cwd(), outputPath )}` ); } ); }; const handleSpaMode = async ({ handler, build, clientBuildDir, basename, api, }: { handler: (request: Request) => Promise<Response>; build: PrerenderServerBuild; clientBuildDir: string; basename: string; api: PrerenderBuildApi; }): Promise<void> => { await withBuildRequest( `http://localhost${basename}`, { headers: { 'X-React-Router-SPA-Mode': 'yes', }, }, async request => { const response = await handler(request); const html = await response.text(); const isPrerenderSpaFallback = build.prerender?.includes('/'); const filename = isPrerenderSpaFallback ? '__spa-fallback.html' : 'index.html'; if (response.status !== 200) { if (isPrerenderSpaFallback) { throw new Error( `Prerender: Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + html ); } throw new Error( `SPA Mode: Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + html ); } if ( !html.includes('window.__reactRouterContext =') || !html.includes('window.__reactRouterRouteModules =') ) { throw new Error( 'SPA Mode: Did you forget to include `<Scripts/>` in your root route? ' + 'Your pre-rendered HTML cannot hydrate without `<Scripts />`.' ); } const outputPath = resolve(clientBuildDir, filename); await writeFile(outputPath, html); const prettyPath = relative(process.cwd(), outputPath); if (build.prerender?.length) { api.logger.info(`Prerender (html): SPA Fallback -> ${prettyPath}`); } else { api.logger.info(`SPA Mode: Generated ${prettyPath}`); } } ); }; const assertValidSsrFalsePrerenderExports = ({ routes, manifestRoutes, routeExports, prerenderPaths, api, }: { routes: Record<string, Route>; manifestRoutes: ReactRouterManifest['routes']; routeExports: RouteManifestModuleExports; prerenderPaths: string[]; api: PrerenderBuildApi; }) => { const errors = getSsrFalsePrerenderExportErrors({ routes, manifestRoutes, routeExports, prerenderPaths, }); if (errors.length > 0) { api.logger.error(errors.join('\n')); throw new Error( 'Invalid route exports found when prerendering with `ssr:false`' ); } }; const validatePrerenderPathMatches = ( routes: Record<string, Route>, prerenderPaths: string[] ): void => { const routeTree = createPrerenderRoutes(routes); for (const path of prerenderPaths) { const matches = matchRoutes(routeTree, normalizePrerenderMatchPath(path)); if (!matches) { throw new Error( `Unable to prerender path because it does not match any routes: ${path}` ); } } }; export const createBoundedPrerenderTasksEffect = ( prerenderPaths: string[], concurrency: number, renderPath: (path: string) => Effect.Effect<void, Error, never> ): Effect.Effect<void, Error, never> => Effect.forEach(prerenderPaths, renderPath, { concurrency, discard: true }); const createPrerenderPathEffect = ({ path, build, buildRoutes, requestHandler, clientBuildDir, options, }: { path: string; build: PrerenderServerBuild; buildRoutes: ReturnType<typeof createPrerenderRoutes>; requestHandler: (request: Request) => Promise<Response>; clientBuildDir: string; options: RunReactRouterPrerenderBuildOptions; }): Effect.Effect<void, Error, never> => Effect.gen(function* () { const { api, basename, future } = options; const matches = matchRoutes(buildRoutes, normalizePrerenderMatchPath(path)); if (!matches) { return; } const leafRoute = matches[matches.length - 1]?.route; const routeId = leafRoute?.id; const manifestRoute = routeId ? build.routes?.[routeId]?.module : null; const isResourceRoute = manifestRoute && !manifestRoute.default && !manifestRoute.ErrorBoundary; if (isResourceRoute) { if (manifestRoute.loader && routeId) { yield* tryPluginPromise(() => prerenderData({ handler: requestHandler, prerenderPath: path, onlyRoutes: [routeId], clientBuildDir, basename, trailingSlashAwareDataRequests: future.unstable_trailingSlashAwareDataRequests, api, }) ); yield* tryPluginPromise(() => prerenderResourceRoute({ handler: requestHandler, prerenderPath: path, clientBuildDir, basename, api, }) ); } else { yield* Effect.sync(() => { api.logger.warn( `⚠️ Skipping prerendering for resource route without a loader: ${routeId}` ); }); } return; } const hasLoaders = matches.some(match => { const matchedRouteId = match.route.id; if (!matchedRouteId) { return false; } return build.assets?.routes?.[matchedRouteId]?.hasLoader; }); const data = hasLoaders ? yield* tryPluginPromise(() => prerenderData({ handler: requestHandler, prerenderPath: path, onlyRoutes: null, clientBuildDir, basename, trailingSlashAwareDataRequests: future.unstable_trailingSlashAwareDataRequests, api, }) ) : undefined; yield* tryPluginPromise(() => prerenderRoute({ handler: requestHandler, prerenderPath: path, clientBuildDir, basename, api, requestInit: data ? createPrerenderDataRequestInit(data) : undefined, }) ); }); const createPrerenderDataRequestInit = ( data: string ): RequestInit | undefined => { const encodedData = encodeURI(data); return encodedData.length < 8 * 1024 ? { headers: { 'X-React-Router-Prerender-Data': encodedData, }, } : undefined; }; const runPrerenderPaths = async ({ build, requestHandler, clientBuildDir, options, }: { build: PrerenderServerBuild; requestHandler: (request: Request) => Promise<Response>; clientBuildDir: string; options: RunReactRouterPrerenderBuildOptions; }): Promise<void> => { const { prerenderConfig, prerenderPaths } = options; const buildRoutes = createPrerenderRoutes(build.routes); const concurrency = getPrerenderConcurrency(prerenderConfig); await runPluginEffect( createBoundedPrerenderTasksEffect(prerenderPaths, concurrency, path => createPrerenderPathEffect({ path, build, buildRoutes, requestHandler, clientBuildDir, options, }) ) ); }; export const runReactRouterPrerenderBuild = async ( options: RunReactRouterPrerenderBuildOptions ): Promise<void> => { if (!options.hasWebEnvironment) { return; } const { api, buildDirectory, serverBuildFile, ssr, isPrerenderEnabled, prerenderPaths, routes, latestBrowserManifest, latestBrowserManifestModuleExports, clientStats, pluginOptions, appDirectory, assetPrefix, routeChunkOptions, buildManifest, resolvedConfigWithRoutes, buildEnd, basename, } = options; const { serverBuildDir, serverBuildPath } = await getServerBuildPath({ buildDirectory, serverBuildFile, }); const clientBuildDir = resolve(buildDirectory, 'client'); if (!existsSync(serverBuildPath)) { console.warn( `[${PLUGIN_NAME}] Server build not found at ${serverBuildPath}. ` + 'Skipping prerendering.' ); return; } await mkdir(clientBuildDir, { recursive: true }); if (!ssr || isPrerenderEnabled) { process.env.IS_RR_BUILD_REQUEST = 'yes'; const buildModule = await import(pathToFileURL(serverBuildPath).toString()); const build = (await resolveServerBuildModule( buildModule, `Server build ${JSON.stringify(serverBuildPath)}` )) as PrerenderServerBuild; const requestHandler = createRequestHandler(build, 'production'); if (isPrerenderEnabled) { if (!ssr) { const generated = latestBrowserManifest ? { manifest: latestBrowserManifest, moduleExportsByRouteId: latestBrowserManifestModuleExports, } : await generateReactRouterManifestForDev( routes, pluginOptions, clientStats, appDirectory, assetPrefix, routeChunkOptions ); assertValidSsrFalsePrerenderExports({ routes, manifestRoutes: generated.manifest.routes, routeExports: generated.moduleExportsByRouteId, prerenderPaths, api, }); } validatePrerenderPathMatches(routes, prerenderPaths); if (prerenderPaths.length > 0) { api.logger.info( `Prerender (html): ${prerenderPaths.length} path(s)...` ); } await runPrerenderPaths({ build, requestHandler, clientBuildDir, options, }); } if (!ssr) { await handleSpaMode({ handler: requestHandler, build, clientBuildDir, basename, api, }); } } if (!ssr) { await fsExtra.remove(serverBuildDir); api.logger.info( `[${PLUGIN_NAME}] Removed server build (static deployment)` ); } if (buildEnd) { await buildEnd({ buildManifest, reactRouterConfig: resolvedConfigWithRoutes, viteConfig: api.getNormalizedConfig(), }); } };