rsbuild-plugin-react-router
Version:
React Router plugin for Rsbuild
592 lines (535 loc) • 17.6 kB
text/typescript
import { createHash } from 'node:crypto';
import { dirname, isAbsolute, relative, resolve } from 'pathe';
import * as Effect from 'effect/Effect';
import type { Route, PluginOptions, RouteManifestItem } from './types.js';
import { combineURLs, createRouteId } from './plugin-utils.js';
import { SERVER_EXPORTS, CLIENT_EXPORTS } from './constants.js';
import {
buildManifestChunkValidity,
createEmptyRouteChunkByExportName,
detectRouteChunksIfEnabled,
getRouteChunkEntryName,
routeChunkExportNames,
validateRouteChunks,
type RouteChunkCache,
type RouteChunkConfig,
} from './route-chunks.js';
import { getRouteModuleAnalysis } from './export-utils.js';
import { getCappedPluginConcurrency } from './concurrency.js';
import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
const ROUTE_ANALYSIS_CONCURRENCY = getCappedPluginConcurrency();
/**
* Structural equivalent of `RouteConfigEntry` from `@react-router/dev/routes`,
* inlined so the public declaration files do not reference a devDependency
* (which would fail to resolve for consumers with `skipLibCheck: false`).
*/
export interface RouteConfigEntry {
id?: string;
path?: string;
index?: boolean;
caseSensitive?: boolean;
file: string;
children?: RouteConfigEntry[];
}
export function configRoutesToRouteManifest(
appDirectory: string,
routes: RouteConfigEntry[],
rootId = 'root'
): Record<string, Route> {
return Object.fromEntries(
configRoutesToRouteManifestEntries(appDirectory, routes, rootId)
);
}
export function configRoutesToRouteManifestEntries(
appDirectory: string,
routes: RouteConfigEntry[],
rootId = 'root'
): Array<[string, Route]> {
const routeManifestEntries: Array<[string, Route]> = [];
const routeIds = new Set<string>();
function walk(route: RouteConfigEntry, parentId: string) {
const id = route.id || createRouteId(route.file);
const manifestItem = {
id,
parentId,
file: isAbsolute(route.file)
? relative(appDirectory, route.file)
: route.file,
path: route.path,
index: route.index,
caseSensitive: route.caseSensitive,
};
if (routeIds.has(id)) {
throw new Error(
`Unable to define routes with duplicate route id: "${id}"`
);
}
routeIds.add(id);
routeManifestEntries.push([id, manifestItem]);
if (route.children) {
for (const child of route.children) {
walk(child, id);
}
}
}
for (const route of routes) {
walk(route, rootId);
}
return routeManifestEntries;
}
type RouteChunkManifestOptions = {
splitRouteModules?: boolean | 'enforce';
rootRouteFile?: string;
isBuild?: boolean;
cache?: RouteChunkCache;
};
export type ReactRouterManifestForDev = {
version: string;
url: string;
hmr?: {
runtime: string;
};
entry: {
module: string;
imports: string[];
css: string[];
};
sri?: Record<string, string> | true;
routes: Record<string, RouteManifestItem>;
};
export type ReactRouterManifestStats = {
assetsByChunkName?: Record<string, string[]>;
entrypointFilesByName?: Record<string, string[]>;
};
type ReactRouterManifestStatsChunk = {
files?: Iterable<string>;
};
type ReactRouterManifestStatsEntrypoint = {
getFiles?: () => Iterable<string>;
};
type ReactRouterManifestStatsLookup<T> = Iterable<[string, T]> & {
get?: (name: string) => T | undefined;
};
type ReactRouterManifestStatsCompilation = {
namedChunks: ReactRouterManifestStatsLookup<ReactRouterManifestStatsChunk>;
entrypoints?: ReactRouterManifestStatsLookup<ReactRouterManifestStatsEntrypoint>;
};
const orderChunkFiles = (chunkName: string, files: string[]): string[] => {
const ownChunkAsset = `${chunkName}.js`;
const ownFileIndex = files.findIndex(file => file.endsWith(ownChunkAsset));
if (ownFileIndex <= 0) {
return files;
}
return [
files[ownFileIndex],
...files.slice(0, ownFileIndex),
...files.slice(ownFileIndex + 1),
];
};
const collectManifestFilesByName = <T>(
items: ReactRouterManifestStatsLookup<T>,
names: ReadonlySet<string> | undefined,
getFiles: (name: string, item: T) => string[]
): Record<string, string[]> => {
const filesByName: Record<string, string[]> = {};
if (!names) {
for (const [name, item] of items) {
filesByName[name] = getFiles(name, item);
}
return filesByName;
}
const missingNames = new Set(names);
if (typeof items.get === 'function') {
for (const name of names) {
const item = items.get(name);
if (!item) {
continue;
}
filesByName[name] = getFiles(name, item);
missingNames.delete(name);
}
}
if (missingNames.size === 0) {
return filesByName;
}
for (const [name, item] of items) {
if (!missingNames.has(name)) {
continue;
}
filesByName[name] = getFiles(name, item);
missingNames.delete(name);
if (missingNames.size === 0) {
break;
}
}
return filesByName;
};
export const createReactRouterManifestStats = (
compilation: ReactRouterManifestStatsCompilation | undefined,
chunkNames?: ReadonlySet<string>
): ReactRouterManifestStats | undefined => {
if (!compilation) {
return undefined;
}
const assetsByChunkName = collectManifestFilesByName(
compilation.namedChunks,
chunkNames,
(chunkName, chunk) =>
orderChunkFiles(chunkName, Array.from(chunk.files ?? []))
);
const entrypointFilesByName = compilation.entrypoints
? collectManifestFilesByName(
compilation.entrypoints,
chunkNames,
(_name, entrypoint) => Array.from(entrypoint.getFiles?.() ?? [])
)
: {};
return Object.keys(entrypointFilesByName).length > 0
? { assetsByChunkName, entrypointFilesByName }
: { assetsByChunkName };
};
export type RouteManifestModuleExports = Record<string, readonly string[]>;
export type ReactRouterManifestGenerationResult = {
manifest: ReactRouterManifestForDev;
moduleExportsByRouteId: RouteManifestModuleExports;
};
type ChunkAssets = {
js: string[];
css: string[];
};
type RouteManifestAnalysis = {
cssAssets: string[];
exports: Set<string>;
routeModuleExports: readonly string[];
hasRouteChunkByExportName: ReturnType<
typeof createEmptyRouteChunkByExportName
> | null;
};
const DEFAULT_MANIFEST_DIR = 'static/js';
const CSS_IMPORT_RE = /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/;
const createChunkAssetResolver = (
clientStats: ReactRouterManifestStats | undefined
): ((chunkName: string) => ChunkAssets) => {
const chunkAssetsByName = new Map<string, ChunkAssets>();
return (chunkName: string): ChunkAssets => {
const cached = chunkAssetsByName.get(chunkName);
if (cached) {
return cached;
}
const assets = clientStats?.assetsByChunkName?.[chunkName];
if (!assets) {
const fallback = `${DEFAULT_MANIFEST_DIR}/${chunkName}.js`;
const result = { js: [fallback], css: [] };
chunkAssetsByName.set(chunkName, result);
return result;
}
const cssAssets = new Set<string>();
const jsAssets: string[] = [];
for (const asset of assets) {
if (asset.endsWith('.css')) {
cssAssets.add(asset);
} else if (asset.endsWith('.js')) {
jsAssets.push(asset);
}
}
for (const asset of clientStats?.entrypointFilesByName?.[chunkName] ?? []) {
if (asset.endsWith('.css')) {
cssAssets.add(asset);
}
}
if (jsAssets.length === 0) {
jsAssets.push(`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`);
}
const result = { js: jsAssets, css: [...cssAssets] };
chunkAssetsByName.set(chunkName, result);
return result;
};
};
const analyzeRouteForManifestEffect = ({
discoveredCssAssets,
isBuild,
routeChunkCache,
routeChunkConfig,
routeEntryName,
routeFilePath,
}: {
discoveredCssAssets: string[];
isBuild: boolean;
routeChunkCache: RouteChunkCache | undefined;
routeChunkConfig: RouteChunkConfig | null;
routeEntryName: string;
routeFilePath: string;
}): Effect.Effect<RouteManifestAnalysis, Error, never> =>
tryPluginPromise(async () => {
const { code, exports: exportNames } =
await getRouteModuleAnalysis(routeFilePath);
const cssAssets =
!isBuild && discoveredCssAssets.length === 0 && CSS_IMPORT_RE.test(code)
? [
`${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`,
]
: discoveredCssAssets;
const chunkInfo =
isBuild && routeChunkConfig
? await detectRouteChunksIfEnabled(
routeChunkCache,
routeChunkConfig,
routeFilePath,
code
)
: null;
return {
cssAssets,
exports: new Set(exportNames),
routeModuleExports: exportNames,
hasRouteChunkByExportName: chunkInfo?.hasRouteChunkByExportName ?? null,
};
}).pipe(
Effect.catchAll(error => {
if (isBuild) {
return Effect.fail(error);
}
return Effect.sync(() => {
console.error(`Failed to analyze route file ${routeFilePath}:`, error);
return {
cssAssets: discoveredCssAssets,
exports: new Set<string>(),
routeModuleExports: [],
hasRouteChunkByExportName: null,
};
});
})
);
const getManifestDirFromEntryAsset = (entryModulePath?: string): string => {
if (!entryModulePath) {
return DEFAULT_MANIFEST_DIR;
}
const dir = dirname(entryModulePath);
return dir === '.' ? DEFAULT_MANIFEST_DIR : dir;
};
export const getReactRouterManifestPath = ({
version,
isBuild,
entryModulePath,
}: {
version: string;
isBuild: boolean;
entryModulePath?: string;
}): string => {
if (!isBuild) {
return 'static/js/virtual/react-router/browser-manifest.js';
}
const dir = getManifestDirFromEntryAsset(entryModulePath);
return `${dir}/manifest-${version}.js`;
};
const getManifestVersion = (
fingerprintedValues: { entry: unknown; routes: unknown },
isBuild: boolean
): string => {
if (!isBuild) {
return String(Math.random());
}
return createHash('md5')
.update(JSON.stringify(fingerprintedValues))
.digest('hex')
.slice(0, 8);
};
const getRouteEntryName = (route: Route): string => {
const extensionIndex = route.file.lastIndexOf('.');
return extensionIndex >= 0 ? route.file.slice(0, extensionIndex) : route.file;
};
export const getReactRouterManifestChunkNames = (
routes: Record<string, Route>,
splitRouteModules: boolean | 'enforce' = false
): Set<string> => {
const chunkNames = new Set<string>(['entry.client']);
for (const route of Object.values(routes)) {
chunkNames.add(getRouteEntryName(route));
if (!splitRouteModules || route.id === 'root') {
continue;
}
for (const exportName of routeChunkExportNames) {
chunkNames.add(getRouteChunkEntryName(route.id, exportName));
}
}
return chunkNames;
};
function generateReactRouterManifestForDevEffect(
routes: Record<string, Route>,
_options: PluginOptions,
clientStats: ReactRouterManifestStats | undefined,
context: string,
assetPrefix: string,
routeChunkOptions?: RouteChunkManifestOptions
): Effect.Effect<ReactRouterManifestGenerationResult, Error, never> {
return Effect.gen(function* () {
const result: Record<string, RouteManifestItem> = {};
const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false;
const enforceSplitRouteModules = splitRouteModules === 'enforce';
const isBuild = routeChunkOptions?.isBuild ?? false;
const routeChunkConfig: RouteChunkConfig | null =
splitRouteModules && routeChunkOptions?.rootRouteFile
? {
splitRouteModules,
appDirectory: context,
rootRouteFile: routeChunkOptions.rootRouteFile,
}
: null;
const getAssetsForChunk = createChunkAssetResolver(clientStats);
const getModulePathForChunk = (chunkName: string): string | undefined => {
const { js: jsAssets } = getAssetsForChunk(chunkName);
return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined;
};
const manifestEntries = yield* Effect.forEach(
Object.entries(routes),
([key, route]) =>
Effect.gen(function* () {
const routeEntryName = getRouteEntryName(route);
const { js: jsAssets, css: discoveredCssAssets } =
getAssetsForChunk(routeEntryName);
const routeFilePath = resolve(context, route.file);
const routeAnalysis = yield* analyzeRouteForManifestEffect({
discoveredCssAssets,
isBuild,
routeChunkCache: routeChunkOptions?.cache,
routeChunkConfig,
routeEntryName,
routeFilePath,
});
const hasClientAction = routeAnalysis.exports.has(
CLIENT_EXPORTS.clientAction
);
const hasClientLoader = routeAnalysis.exports.has(
CLIENT_EXPORTS.clientLoader
);
const hasClientMiddleware = routeAnalysis.exports.has(
CLIENT_EXPORTS.clientMiddleware
);
const hasDefaultExport = routeAnalysis.exports.has('default');
const routeChunkMap = routeAnalysis.hasRouteChunkByExportName;
if (isBuild && enforceSplitRouteModules && routeChunkConfig) {
validateRouteChunks({
config: routeChunkConfig,
id: routeFilePath,
valid: buildManifestChunkValidity(
routeAnalysis.exports,
routeChunkMap ?? createEmptyRouteChunkByExportName()
),
});
}
return [
key,
{
id: route.id,
parentId: route.parentId,
path: route.path,
index: route.index,
caseSensitive: route.caseSensitive,
module: combineURLs(assetPrefix, jsAssets[0] || ''),
clientActionModule: routeChunkMap?.clientAction
? getModulePathForChunk(
getRouteChunkEntryName(route.id, 'clientAction')
)
: undefined,
clientLoaderModule: routeChunkMap?.clientLoader
? getModulePathForChunk(
getRouteChunkEntryName(route.id, 'clientLoader')
)
: undefined,
clientMiddlewareModule: routeChunkMap?.clientMiddleware
? getModulePathForChunk(
getRouteChunkEntryName(route.id, 'clientMiddleware')
)
: undefined,
hydrateFallbackModule: routeChunkMap?.HydrateFallback
? getModulePathForChunk(
getRouteChunkEntryName(route.id, 'HydrateFallback')
)
: undefined,
hasAction: routeAnalysis.exports.has(SERVER_EXPORTS.action),
hasLoader: routeAnalysis.exports.has(SERVER_EXPORTS.loader),
hasClientAction,
hasClientLoader,
hasClientMiddleware,
hasDefaultExport,
hasErrorBoundary: routeAnalysis.exports.has(
CLIENT_EXPORTS.ErrorBoundary
),
imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)),
css: routeAnalysis.cssAssets.map(asset =>
combineURLs(assetPrefix, asset)
),
},
routeAnalysis.routeModuleExports,
] as const;
}),
{
concurrency: Math.max(
1,
Math.min(ROUTE_ANALYSIS_CONCURRENCY, Object.keys(routes).length)
),
}
);
const routeModuleExportsByRouteId: RouteManifestModuleExports = {};
for (const [
key,
routeManifestItem,
routeModuleExports,
] of manifestEntries) {
result[key] = routeManifestItem;
routeModuleExportsByRouteId[key] = routeModuleExports;
}
const { js: entryJsAssets, css: entryCssAssets } =
getAssetsForChunk('entry.client');
const fingerprintedValues = {
entry: {
module: combineURLs(assetPrefix, entryJsAssets[0] || ''),
imports: entryJsAssets.map(asset => combineURLs(assetPrefix, asset)),
css: entryCssAssets.map(asset => combineURLs(assetPrefix, asset)),
},
routes: result,
};
const version = getManifestVersion(fingerprintedValues, isBuild);
const manifestPath = getReactRouterManifestPath({
version,
isBuild,
entryModulePath: entryJsAssets[0],
});
const manifest = {
version,
url: combineURLs(assetPrefix, manifestPath),
hmr: undefined,
entry: fingerprintedValues.entry,
sri: undefined,
routes: result,
};
return {
manifest,
moduleExportsByRouteId: routeModuleExportsByRouteId,
};
});
}
export async function generateReactRouterManifestForDev(
routes: Record<string, Route>,
options: PluginOptions,
clientStats: ReactRouterManifestStats | undefined,
context: string,
assetPrefix = '/',
routeChunkOptions?: RouteChunkManifestOptions
): Promise<ReactRouterManifestGenerationResult> {
return runPluginEffect(
generateReactRouterManifestForDevEffect(
routes,
options,
clientStats,
context,
assetPrefix,
routeChunkOptions
)
);
}
export async function getReactRouterManifestForDev(
...args: Parameters<typeof generateReactRouterManifestForDev>
): Promise<ReactRouterManifestForDev> {
return (await generateReactRouterManifestForDev(...args)).manifest;
}