rsbuild-plugin-react-router
Version:
React Router plugin for Rsbuild
708 lines (657 loc) • 19.6 kB
text/typescript
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(),
});
}
};