UNPKG

rsbuild-plugin-react-router

Version:
587 lines (559 loc) 18.2 kB
import type { RsbuildConfig, RsbuildPluginAPI, Rspack } from '@rsbuild/core'; import * as Effect from 'effect/Effect'; import type { ServerBuild } from 'react-router'; import { PLUGIN_NAME } from './constants.js'; import { beginDevCompilerAttempt, clearDevCompilerStart, createCompilationIdentityTracker, createDevCompilerPair, hasPendingCompilation, isLatestStartedCompilation, markDevCompilerPending, resetDevCompilerPair, type DevCompilerPair, } from './dev-runtime-compilation.js'; import { createReactRouterDevRuntime, loadReactRouterServerBuild, registerReactRouterDevRuntime, unregisterReactRouterDevRuntime, } from './dev-generation.js'; import { getEnvironmentStats, snapshotDevChangedFiles, type DevGraphIdentity, type DevRuntimeStats, type ReactRouterDevBuildPlan, type ReactRouterDevManifestSet, } from './dev-runtime-artifacts.js'; import { DEV_HDR_REVISION_RELATIVE_PATH } from './dev-hmr.js'; import { createDevRuntimeSessionManager, type RuntimeBinding, } from './dev-runtime-session.js'; import { normalizeEffectError, runPluginEffect, tryPluginPromise, tryPluginSync, } from './effect-runtime.js'; type ServerSetup = Exclude< NonNullable<NonNullable<RsbuildConfig['server']>['setup']>, unknown[] >; export type ReactRouterDevRuntimeController = { captureWeb: ( compilation: Rspack.Compilation, manifestsByEntryName: ReactRouterDevManifestSet ) => void; createBuildLoader: (entryName?: string) => () => Promise<ServerBuild>; }; type CreateControllerOptions = { api: RsbuildPluginAPI; isBuild: boolean; buildPlan: ReactRouterDevBuildPlan; /** * Invoked after a development attempt commits a re-evaluated node build for * changed server files. Used to signal hot data revalidation to the client. */ onNodeRebuildCommitted?: () => void; }; const escapeHtml = (value: string): string => value .replaceAll('&', '&amp;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;'); const CSS_SOURCE_RELOAD_DELAY_MS = 1000; const isHdrRevisionFile = (file: string): boolean => file.includes(DEV_HDR_REVISION_RELATIVE_PATH); const isCssSourceFile = (file: string): boolean => /\.css(?:\.[cm]?[jt]s)?$/.test(file); // A change that should bump the HDR revision: anything except the revision // file itself (the bump's own echo) and CSS sources (styling never changes // loader data). const hasHdrTriggeringChange = (files: Iterable<string>): boolean => { for (const file of files) { if (!isHdrRevisionFile(file) && !isCssSourceFile(file)) { return true; } } return false; }; export const createReactRouterDevRuntimeController = ({ api, isBuild, buildPlan, onNodeRebuildCommitted, }: CreateControllerOptions): ReactRouterDevRuntimeController => { if (isBuild) { return { captureWeb() {}, createBuildLoader() { return () => Promise.reject( new Error( `[${PLUGIN_NAME}] The development server runtime is unavailable during a production build.` ) ); }, }; } let scheduledCssAssetOwnershipReload: | ReturnType<typeof setTimeout> | undefined; let lastCssAssetOwnershipReloadAt = 0; let reloadAfterCssAssetOwnershipRemoval = false; const sendCssAssetOwnershipReload = (): void => { const binding = sessions.getActiveBinding(); if (!binding) { return; } lastCssAssetOwnershipReloadAt = Date.now(); binding.server.sockWrite('full-reload', { path: '*' }); }; const scheduleCssAssetOwnershipReload = (): void => { if (scheduledCssAssetOwnershipReload) { clearTimeout(scheduledCssAssetOwnershipReload); } const scheduledAt = Date.now(); scheduledCssAssetOwnershipReload = setTimeout(() => { scheduledCssAssetOwnershipReload = undefined; if (lastCssAssetOwnershipReloadAt > scheduledAt) { return; } sendCssAssetOwnershipReload(); }, CSS_SOURCE_RELOAD_DELAY_MS); }; const closeBinding = (binding: RuntimeBinding, error?: Error): void => { if (scheduledCssAssetOwnershipReload) { clearTimeout(scheduledCssAssetOwnershipReload); scheduledCssAssetOwnershipReload = undefined; } reloadAfterCssAssetOwnershipRemoval = false; const pair = binding.compilers; if (pair) { resetDevCompilerPair(pair); } binding.compilers = undefined; binding.runtime.close(error); unregisterReactRouterDevRuntime(binding.server, binding.runtime); }; const sessions = createDevRuntimeSessionManager(closeBinding); const compilationIdentities = createCompilationIdentityTracker(); const { getCompilationIdentity } = compilationIdentities; // Web-only commits reuse the node compiler's stale `modifiedFiles` // snapshot, and every HDR bump itself triggers a web rebuild — so signal // once per node compilation identity or the bump loop self-sustains. const hdrSignaledNodeIdentity = new WeakMap< DevCompilerPair, NonNullable<DevGraphIdentity['node']> >(); const finishRuntimeAttemptEffect = ( binding: RuntimeBinding, pair: DevCompilerPair, stats: DevRuntimeStats, changes: Parameters<RuntimeBinding['runtime']['finishAttempt']>[1], identity: Parameters<RuntimeBinding['runtime']['finishAttempt']>[2] ): Effect.Effect<void, Error, never> => tryPluginPromise(() => binding.runtime.finishAttempt(stats, changes, identity) ).pipe( Effect.flatMap(result => tryPluginSync(() => { if (sessions.getActiveBinding()?.id !== binding.id) { return; } if (result === 'retry-node') { pair.node.watching?.invalidate(); return; } if ( result === 'committed' && changes.node.known && identity.node !== undefined && hdrSignaledNodeIdentity.get(pair) !== identity.node && hasHdrTriggeringChange(changes.node.files) ) { hdrSignaledNodeIdentity.set(pair, identity.node); onNodeRebuildCommitted?.(); } }) ), Effect.catchAll(cause => tryPluginSync(() => { if (sessions.getActiveBinding()?.id === binding.id) { binding.runtime.failAttempt(normalizeEffectError(cause)); } }) ) ); const finishRuntimeAttempt = ( binding: RuntimeBinding, pair: DevCompilerPair, stats: DevRuntimeStats, changes: Parameters<RuntimeBinding['runtime']['finishAttempt']>[1], identity: Parameters<RuntimeBinding['runtime']['finishAttempt']>[2] ): Promise<void> => runPluginEffect( finishRuntimeAttemptEffect(binding, pair, stats, changes, identity) ); const flushSettledAttempt = ( binding: RuntimeBinding, pair: DevCompilerPair ): void => { const pending = pair.pendingAttempt; if ( !pending || sessions.getActiveBinding()?.id !== binding.id || !pair.settledCompilations.has(pending.webCompilation) || !pair.settledCompilations.has(pending.nodeCompilation) ) { return; } pair.pendingAttempt = undefined; if ( !isLatestStartedCompilation(pending.identity.web, pair.latestWebStart) || !isLatestStartedCompilation(pending.identity.node, pair.latestNodeStart) ) { return; } void finishRuntimeAttempt( binding, pair, pending.stats, pending.changes, pending.identity ); }; const rejectUnsupportedCompiler = (reason: string): void => { const message = `[${PLUGIN_NAME}] Could not coordinate React Router development output because ${reason}.`; api.logger.warn(message); const binding = sessions.getActiveBinding(); if (!binding) { return; } const error = new Error(message); sessions.terminate(binding, error); }; // Rsbuild runs server.setup before onBeforeStartDevServer. Prepending the // observer here ensures setup callbacks cannot capture an unobserved close. api.modifyRsbuildConfig({ order: 'post', handler(config) { const existingSetup = config.server?.setup; const setup = existingSetup ? Array.isArray(existingSetup) ? existingSetup : [existingSetup] : []; const observeServer: ServerSetup = context => { if (context.action === 'dev') { sessions.observeClose(context.server); } }; return { ...config, server: { ...config.server, setup: [observeServer, ...setup], }, }; }, }); api.onBeforeStartDevServer({ order: 'pre', async handler({ server }) { sessions.assertCanStart(); const runtime = createReactRouterDevRuntime({ server, buildPlan, onEvaluationError(error) { if (sessions.getActiveBinding()?.runtime !== runtime) { return; } api.logger.error(error.message); server.sockWrite('errors', { text: [error.message], html: escapeHtml(error.message), }); }, onCssAssetOwnershipChanged(change) { if (sessions.getActiveBinding()?.runtime !== runtime) { return; } reloadAfterCssAssetOwnershipRemoval = change === 'removed'; sendCssAssetOwnershipReload(); }, onRouteManifestChanged() { if (sessions.getActiveBinding()?.runtime !== runtime) { return; } server.sockWrite('full-reload', { path: '*' }); }, onWarning: message => api.logger.warn(message), }); const binding = sessions.createBinding(server, runtime); registerReactRouterDevRuntime(server, runtime); sessions.bindCloseObservation(binding); }, }); api.onCloseDevServer({ order: 'pre', handler() { const binding = sessions.getActiveBinding(); if (!binding) { return; } closeBinding(binding); sessions.markClosing(binding); }, }); api.onBeforeDevCompile({ order: 'pre', handler() { const binding = sessions.getActiveBinding(); const pair = binding?.compilers; if (!binding || !pair || hasPendingCompilation(pair)) { return; } beginDevCompilerAttempt(pair); binding.runtime.beginAttempt(); }, }); api.onAfterCreateCompiler(({ compiler }) => { if (!('compilers' in compiler)) { rejectUnsupportedCompiler('Rsbuild did not create a multi-compiler'); return; } const web = compiler.compilers.find(item => item.name === 'web'); const node = compiler.compilers.find(item => item.name === 'node'); if (!web || !node) { rejectUnsupportedCompiler('the web or node compiler was missing'); return; } const binding = sessions.getActiveBinding(); if (!binding) { return; } const pair: DevCompilerPair = createDevCompilerPair({ web, node }); binding.compilers = pair; const sessionId = binding.id; const runtime = binding.runtime; const failCurrentAttempt = (side: 'web' | 'node', error: Error): void => { if (sessions.getActiveBinding()?.id === sessionId) { if (side === 'web') { clearDevCompilerStart(pair, 'latestWebStart'); } else { clearDevCompilerStart(pair, 'latestNodeStart'); } runtime.failAttempt(error); } }; const beginCompilerAttempt = ( side: 'latestWebStart' | 'latestNodeStart' ): void => { if ( sessions.getActiveBinding()?.id === sessionId && pair[side]?.status !== 'pending' ) { // Invalidation can arrive before the aggregate before-compile hook. // Supersede any evaluation that could resolve in that gap immediately. if (markDevCompilerPending(pair, side)) { runtime.beginAttempt(); } if (side === 'latestWebStart' && reloadAfterCssAssetOwnershipRemoval) { reloadAfterCssAssetOwnershipRemoval = false; scheduleCssAssetOwnershipReload(); } } }; web.hooks.invalid.tap(`${PLUGIN_NAME}:dev-web-invalid`, () => beginCompilerAttempt('latestWebStart') ); node.hooks.invalid.tap(`${PLUGIN_NAME}:dev-node-invalid`, () => beginCompilerAttempt('latestNodeStart') ); web.hooks.done.tap( { name: `${PLUGIN_NAME}:dev-web-complete`, stage: -1000 }, stats => { if (sessions.getActiveBinding()?.id !== sessionId) { return; } pair.latestCompletedWebIdentity = getCompilationIdentity( stats.compilation ); pair.latestCompletedWebStats = stats; } ); node.hooks.done.tap( { name: `${PLUGIN_NAME}:dev-node-complete`, stage: -1000 }, stats => { if (sessions.getActiveBinding()?.id === sessionId) { pair.latestCompletedNodeStats = stats; } } ); web.hooks.thisCompilation.tap( `${PLUGIN_NAME}:dev-web-compilation`, compilation => { if (sessions.getActiveBinding()?.id === sessionId) { if (pair.currentAttemptIdentity) { compilationIdentities.setAttemptIdentityForCompilation( compilation, pair.currentAttemptIdentity ); } pair.latestWebStart = { status: 'started', identity: getCompilationIdentity(compilation), }; if (reloadAfterCssAssetOwnershipRemoval) { reloadAfterCssAssetOwnershipRemoval = false; scheduleCssAssetOwnershipReload(); } } } ); node.hooks.thisCompilation.tap( `${PLUGIN_NAME}:dev-node-web-compilation`, compilation => { if (sessions.getActiveBinding()?.id !== sessionId) { return; } pair.latestNodeStart = { status: 'started', identity: getCompilationIdentity(compilation), }; if (pair.currentAttemptIdentity) { compilationIdentities.setAttemptIdentityForCompilation( compilation, pair.currentAttemptIdentity ); } if (pair.latestCompletedWebIdentity) { compilationIdentities.setWebIdentityForNodeCompilation( compilation, pair.latestCompletedWebIdentity ); } } ); const settleCompilation = (stats: Rspack.Stats): void => { if (sessions.getActiveBinding()?.id !== sessionId) { return; } pair.settledCompilations.add(stats.compilation); flushSettledAttempt(binding, pair); }; web.hooks.afterDone.tap( `${PLUGIN_NAME}:dev-web-settled`, settleCompilation ); node.hooks.afterDone.tap( `${PLUGIN_NAME}:dev-node-settled`, settleCompilation ); web.hooks.failed.tap(`${PLUGIN_NAME}:dev-web-failed`, error => failCurrentAttempt('web', error) ); node.hooks.failed.tap(`${PLUGIN_NAME}:dev-node-failed`, error => failCurrentAttempt('node', error) ); }); api.onAfterDevCompile(async ({ stats }) => { const binding = sessions.getActiveBinding(); const pair = binding?.compilers; if (!binding || !pair) { return; } const webStats = getEnvironmentStats(stats, 'web') ?? pair.latestCompletedWebStats; const nodeStats = getEnvironmentStats(stats, 'node') ?? pair.latestCompletedNodeStats; if ( (webStats && webStats.compilation.compiler !== pair.web) || (nodeStats && nodeStats.compilation.compiler !== pair.node) ) { return; } const webIdentity = webStats ? getCompilationIdentity(webStats.compilation) : undefined; const nodeIdentity = nodeStats ? getCompilationIdentity(nodeStats.compilation) : undefined; if ( !isLatestStartedCompilation(webIdentity, pair.latestWebStart) || !isLatestStartedCompilation(nodeIdentity, pair.latestNodeStart) ) { return; } const changes = { web: snapshotDevChangedFiles(pair.web), node: snapshotDevChangedFiles(pair.node), }; const webAttempt = webStats ? compilationIdentities.getAttemptIdentityForCompilation( webStats.compilation ) : undefined; const nodeAttempt = nodeStats ? compilationIdentities.getAttemptIdentityForCompilation( nodeStats.compilation ) : undefined; const identity: DevGraphIdentity = { web: webIdentity, node: nodeIdentity, nodeWeb: nodeStats ? compilationIdentities.getWebIdentityForNodeCompilation( nodeStats.compilation ) : undefined, attempt: webAttempt && nodeAttempt && webAttempt === nodeAttempt ? webAttempt : undefined, }; const finishStats: DevRuntimeStats = webStats && nodeStats ? { web: webStats, node: nodeStats } : stats; if (!webStats || !nodeStats) { await finishRuntimeAttempt(binding, pair, finishStats, changes, identity); return; } pair.pendingAttempt = { stats: finishStats, changes, identity, webCompilation: webStats.compilation, nodeCompilation: nodeStats.compilation, }; flushSettledAttempt(binding, pair); }); return { captureWeb(compilation, manifestsByEntryName): void { const binding = sessions.getActiveBinding(); if (binding?.compilers?.web === compilation.compiler) { binding.runtime.captureWeb(compilation, manifestsByEntryName); } }, createBuildLoader(entryName?: string): () => Promise<ServerBuild> { const server = sessions.getActiveBinding()?.server; if (server) { return () => loadReactRouterServerBuild(server, entryName); } const state = sessions.getState(); if (state.status === 'terminal') { const { error } = state; return () => Promise.reject(error); } return () => Promise.reject( new Error( `[${PLUGIN_NAME}] The development server runtime is not ready.` ) ); }, }; };