diff --git a/.changeset/spa-build-process.md b/.changeset/spa-build-process.md new file mode 100644 index 00000000..d14dec31 --- /dev/null +++ b/.changeset/spa-build-process.md @@ -0,0 +1,8 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Fix two `ssr: false` / prerender build issues: + +- `rsbuild build` no longer hangs when the app's server graph opens a ref'd handle at module scope (for example a `BroadcastChannel`). Build-time rendering (SPA-mode `index.html` and prerendering, classic and RSC) now evaluates the server bundle in a worker thread that is terminated once rendering is done, instead of importing it into the build process (#135). +- With `performance.buildCache` enabled, a warm build no longer renders `index.html` against the previous build's asset URLs. The server-manifest module now declares a file dependency on the captured manifest, so Rspack's persistent cache invalidates it whenever the web build's asset names change (#136). diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bf20c0bd..51c0ee28 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -44,12 +44,13 @@ jobs: - name: Install dependencies run: pnpm install - - name: Run unit tests - run: pnpm test - + # Unit tests exercise the built server-build worker (dist/). - name: Build package run: pnpm build + - name: Run unit tests + run: pnpm test + - name: Run publint run: npx publint --errors-only diff --git a/README.md b/README.md index 3b591e88..53690829 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ React Router's SPA Mode still requires a build-time server render of the root ro When `ssr: false`: - The plugin builds both `web` and `node` internally. -- It generates `build/client/index.html` by running the server build once (requesting `basename` with the `X-React-Router-SPA-Mode: yes` header). +- It generates `build/client/index.html` by running the server build once (requesting `basename` with the `X-React-Router-SPA-Mode: yes` header). The server bundle is evaluated in a worker thread that is terminated afterwards, with `process.env.IS_RR_BUILD_REQUEST === 'yes'` set, so module-scope side effects in your root route's import graph run at build time but cannot keep `rsbuild build` alive. The same applies to prerendering. - It removes `build/server` after generating `index.html`, so the output is deployable as static assets. **Important:** In SPA mode, use `clientLoader` instead of `loader` for data loading since there's no server at runtime. diff --git a/rslib.config.ts b/rslib.config.ts index 48abc6f7..14a20cc8 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -14,6 +14,7 @@ const config = defineConfig({ 'route-module-transform-loader': './src/route-module-transform-loader.ts', 'rsc-route-transform-loader': './src/rsc-route-transform-loader.ts', + 'server-build-worker': './src/server-build-worker.ts', 'templates/entry.server': './src/templates/entry.server.tsx', 'templates/entry.client': './src/templates/entry.client.tsx', 'templates/entry.rsc': './src/templates/entry.rsc.tsx', diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index 32bb4be5..cb3379af 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import type { RsbuildPluginAPI, TransformHandler } from '@rsbuild/core'; import jsesc from 'jsesc'; import { relative } from 'pathe'; @@ -80,6 +81,8 @@ type RegisterBuildOutputTransformsOptions = { resolvedServerOutput: 'module' | 'commonjs'; performanceProfiler: ReactRouterPerformanceProfiler; getLatestServerManifest: () => ReactRouterManifest | null; + /** File holding the captured manifests; a dependency of the server-manifest module. */ + serverManifestStampPath: string; getLatestServerManifestByBundleId: ( bundleId: string ) => ReactRouterManifest | undefined; @@ -112,6 +115,7 @@ export const registerBuildOutputTransforms = ({ resolvedServerOutput, performanceProfiler, getLatestServerManifest, + serverManifestStampPath, getLatestServerManifestByBundleId, routes, pluginOptions, @@ -205,6 +209,13 @@ export const registerBuildOutputTransforms = ({ }; } + // Cache identity for a module whose source never changes (#136); + // see `serverManifestStampPath` in index.ts. + if (existsSync(serverManifestStampPath)) { + args.addDependency(serverManifestStampPath); + } else { + args.addMissingDependency(serverManifestStampPath); + } const bundleMatch = args.resource.match( /virtual\/react-router\/server-manifest(?:-([^?]+))?/ ); diff --git a/src/index.ts b/src/index.ts index 6491e5b3..3c059655 100644 --- a/src/index.ts +++ b/src/index.ts @@ -530,6 +530,30 @@ export const pluginReactRouter = ( let latestServerManifest: ReactRouterManifest | null = null; const latestServerManifestsByBundleId: Record = {}; + // The node `server-manifest` module's source is a constant; its real + // content is injected by a transform from the web compilation's emitted + // asset names. Rspack's persistent cache would therefore reuse a previous + // build's module even when those names changed (#136). The transform + // declares this file, which holds the captured manifests, as a file + // dependency so the cache invalidates exactly when the manifest changes. + const serverManifestStampPath = resolve( + api.context.cachePath, + 'react-router', + 'server-manifest.json' + ); + // Bundle manifests derive from the base one, so the base is the stamp. + // Only rewrite on change: a bumped mtime would otherwise invalidate the + // module on every build and, if the cache dir is watched, rebuild node + // after every web rebuild in dev. + const writeServerManifestStamp = (): void => { + const stamp = JSON.stringify(latestServerManifest); + const previous = existsSync(serverManifestStampPath) + ? readFileSync(serverManifestStampPath, 'utf8') + : undefined; + if (stamp !== previous) { + fsExtra.outputFileSync(serverManifestStampPath, stamp); + } + }; const routeByFilePath = new Map( Object.values(routes).map(route => [ @@ -756,6 +780,7 @@ export const pluginReactRouter = ( latestServerManifestsByBundleId[bundleId] = bundleManifest; manifestsByEntryName[entryName] = bundleManifest; } + writeServerManifestStamp(); if (!isBuild) { modePlan.artifacts.devRuntime.captureWeb( @@ -1159,6 +1184,7 @@ export const pluginReactRouter = ( resolvedServerOutput, performanceProfiler, getLatestServerManifest: () => latestServerManifest, + serverManifestStampPath, getLatestServerManifestByBundleId: bundleId => latestServerManifestsByBundleId[bundleId], routes, diff --git a/src/prerender-build.ts b/src/prerender-build.ts index 7c6e18e2..c17dd865 100644 --- a/src/prerender-build.ts +++ b/src/prerender-build.ts @@ -1,14 +1,9 @@ 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 { matchRoutes } from 'react-router'; import { dirname, relative, resolve } from 'pathe'; import { PLUGIN_NAME, SPA_FALLBACK_HTML_FILE } from './constants.js'; import { getBuildManifest } from './build-manifest.js'; @@ -31,24 +26,11 @@ import type { Config, ResolvedReactRouterConfig, } from './react-router-config.js'; -import { resolveServerBuildModule } from './server-utils.js'; +import { startServerBuildWorker } from './server-build-worker-client.js'; +import type { ServerBuildDescription } from './server-build-worker-protocol.js'; import type { PluginOptions, Route } from './types.js'; import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; -type BuildRouteModule = { - loader?: unknown; - default?: unknown; - ErrorBoundary?: unknown; -}; - -type PrerenderServerBuild = ServerBuild & { - routes: Record; - assets?: { - routes?: Record; - }; - prerender?: string[]; -}; - type PrerenderBuildApi = Pick< RsbuildPluginAPI, 'logger' | 'getNormalizedConfig' @@ -344,7 +326,7 @@ const handleSpaMode = async ({ api, }: { handler: (request: Request) => Promise; - build: PrerenderServerBuild; + build: ServerBuildDescription; clientBuildDir: string; basename: string; api: PrerenderBuildApi; @@ -459,7 +441,7 @@ const createPrerenderPathEffect = ({ options, }: { path: string; - build: PrerenderServerBuild; + build: ServerBuildDescription; buildRoutes: ReturnType; requestHandler: (request: Request) => Promise; clientBuildDir: string; @@ -596,75 +578,83 @@ export const runReactRouterPrerenderBuild = async ( 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, - createReactRouterManifestOptions({ - routeChunks: routeChunkOptions, - routeModuleAnalysis, - }) - ); - assertValidSsrFalsePrerenderExports({ - routes, - manifestRoutes: generated.manifest.routes, - routeExports: generated.moduleExportsByRouteId, - prerenderPaths, - api, - }); + const worker = await startServerBuildWorker({ + serverBuildPath, + mode: 'classic', + }); + try { + const build = worker.description; + if (!build) { + throw new Error( + `[${PLUGIN_NAME}] Server build worker returned no build description` + ); } + const requestHandler = worker.handler; + + if (isPrerenderEnabled) { + if (!ssr) { + const generated = latestBrowserManifest + ? { + manifest: latestBrowserManifest, + moduleExportsByRouteId: latestBrowserManifestModuleExports, + } + : await generateReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + createReactRouterManifestOptions({ + routeChunks: routeChunkOptions, + routeModuleAnalysis, + }) + ); + assertValidSsrFalsePrerenderExports({ + routes, + manifestRoutes: generated.manifest.routes, + routeExports: generated.moduleExportsByRouteId, + prerenderPaths, + api, + }); + } + + validatePrerenderPathMatches(routes, prerenderPaths); - validatePrerenderPathMatches(routes, prerenderPaths); + if (prerenderPaths.length > 0) { + api.logger.info( + `Prerender (html): ${prerenderPaths.length} path(s)...` + ); + } - if (prerenderPaths.length > 0) { - api.logger.info( - `Prerender (html): ${prerenderPaths.length} path(s)...` + const buildRoutes = createPrerenderRoutes(build.routes); + await runPluginEffect( + createBoundedPrerenderTasksEffect( + prerenderPaths, + getPrerenderConcurrency(prerenderConfig), + path => + createPrerenderPathEffect({ + path, + build, + buildRoutes, + requestHandler, + clientBuildDir, + options, + }) + ) ); } - const buildRoutes = createPrerenderRoutes(build.routes); - await runPluginEffect( - createBoundedPrerenderTasksEffect( - prerenderPaths, - getPrerenderConcurrency(prerenderConfig), - path => - createPrerenderPathEffect({ - path, - build, - buildRoutes, - requestHandler, - clientBuildDir, - options, - }) - ) - ); - } - - if (!ssr) { - await handleSpaMode({ - handler: requestHandler, - build, - clientBuildDir, - basename, - api, - }); + if (!ssr) { + await handleSpaMode({ + handler: requestHandler, + build, + clientBuildDir, + basename, + api, + }); + } + } finally { + await worker.close(); } } diff --git a/src/rsc-prerender.ts b/src/rsc-prerender.ts index 3d8c7b14..93db35ce 100644 --- a/src/rsc-prerender.ts +++ b/src/rsc-prerender.ts @@ -1,10 +1,10 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; import type { RsbuildPluginAPI } from '@rsbuild/core'; import { dirname, relative, resolve } from 'pathe'; import * as Effect from 'effect/Effect'; import { PLUGIN_NAME, SPA_FALLBACK_HTML_FILE } from './constants.js'; +import { startServerBuildWorker } from './server-build-worker-client.js'; import { createBuildRequestEffect, createBoundedPrerenderTasksEffect, @@ -27,8 +27,9 @@ import { runPluginEffect } from './effect-runtime.js'; * inline `__FLIGHT_DATA` scripts, served for client-side navigations * * Instead of an HTTP round-trip through a preview server, the RSC server - * bundle's default-exported `fetch` handler is invoked in-process, matching - * how classic mode prerenders through `createRequestHandler`. + * bundle's default-exported `fetch` handler is invoked directly (in the + * server build worker), matching how classic mode prerenders through + * `createRequestHandler`. */ export const SPA_FALLBACK_REQUEST_PATH: string = `/${SPA_FALLBACK_HTML_FILE}`; @@ -145,29 +146,6 @@ const createRedirectHtml = ({ `; }; -const resolveRscRequestHandler = ( - buildModule: unknown, - serverBuildPath: string -): RscRequestHandler => { - const moduleRecord = buildModule as - | { default?: { fetch?: unknown; default?: { fetch?: unknown } } } - | undefined; - const handler = - typeof moduleRecord?.default?.fetch === 'function' - ? moduleRecord.default.fetch - : typeof moduleRecord?.default?.default?.fetch === 'function' - ? moduleRecord.default.default.fetch - : null; - if (!handler) { - throw new Error( - `[${PLUGIN_NAME}] RSC server build ${JSON.stringify( - serverBuildPath - )} must default-export an object with a fetch function.` - ); - } - return handler as RscRequestHandler; -}; - const writePrerenderedFile = async ({ api, clientBuildDir, @@ -330,11 +308,9 @@ export const runReactRouterRscPrerenderBuild = async ( const clientBuildDir = resolve(buildDirectory, 'client'); await mkdir(clientBuildDir, { recursive: true }); - const previousBuildRequestFlag = process.env.IS_RR_BUILD_REQUEST; - process.env.IS_RR_BUILD_REQUEST = 'yes'; + const worker = await startServerBuildWorker({ serverBuildPath, mode: 'rsc' }); try { - const buildModule = await import(pathToFileURL(serverBuildPath).toString()); - const handler = resolveRscRequestHandler(buildModule, serverBuildPath); + const handler: RscRequestHandler = worker.handler; api.logger.info(`Prerender: ${prerenderRequests.length} path(s)...`); @@ -353,10 +329,6 @@ export const runReactRouterRscPrerenderBuild = async ( ) ); } finally { - if (previousBuildRequestFlag === undefined) { - delete process.env.IS_RR_BUILD_REQUEST; - } else { - process.env.IS_RR_BUILD_REQUEST = previousBuildRequestFlag; - } + await worker.close(); } }; diff --git a/src/server-build-resolution.ts b/src/server-build-resolution.ts index 4d33b5ac..433a0f78 100644 --- a/src/server-build-resolution.ts +++ b/src/server-build-resolution.ts @@ -1,7 +1,6 @@ // Internal module: exposes ServerBuild resolution used by dev-runtime code. // External callers go through the Promise wrappers in server-utils.ts. import type { ServerBuild } from 'react-router'; -import { normalizeEffectError } from './effect-runtime.js'; const RESOLVABLE_BUILD_EXPORTS = new Set([ 'allowedActionOrigins', @@ -116,6 +115,6 @@ export async function resolveServerBuildModule( `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.` ); } catch (cause) { - throw normalizeEffectError(cause); + throw cause instanceof Error ? cause : new Error(String(cause)); } } diff --git a/src/server-build-worker-client.ts b/src/server-build-worker-client.ts new file mode 100644 index 00000000..b2d193ea --- /dev/null +++ b/src/server-build-worker-client.ts @@ -0,0 +1,153 @@ +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; +import { normalizeEffectError } from './effect-runtime.js'; +import { + headerEntries, + type ServerBuildDescription, + type ServerBuildWorkerData, + type ServerBuildWorkerRequest, + type ServerBuildWorkerResponse, +} from './server-build-worker-protocol.js'; + +const defaultWorkerPath = fileURLToPath( + new URL('./server-build-worker.js', import.meta.url) +); + +export type ServerBuildWorker = { + /** Plain-data view of the classic server build (routes, assets, prerender). */ + description: ServerBuildDescription | undefined; + /** Runs the request against the server build in the worker. */ + handler(request: Request): Promise; + /** Terminates the worker, and with it any handle the server graph opened. */ + close(): Promise; +}; + +type Reply = Extract; + +type Pending = { + resolve: (reply: Reply) => void; + reject: (error: Error) => void; +}; + +const replyError = (reply: Extract): Error => { + const error = new Error(reply.error.message); + error.name = reply.error.name ?? error.name; + if (reply.error.stack) { + error.stack = reply.error.stack; + } + return error; +}; + +/** + * Evaluate a built server bundle in a worker thread and proxy requests to it. + * Build-time rendering used to `import()` the bundle into the build process; + * a module-scope handle in the app's server graph then kept `rsbuild build` + * alive forever (#135). The worker is terminated by `close()`. + * + * The worker's `exit` is its final event, so any exit (including one between + * requests, e.g. the app calling `process.exit`) is terminal: outstanding and + * later requests reject instead of waiting for a reply that cannot come. + */ +export const startServerBuildWorker = async ( + data: ServerBuildWorkerData, + // Tests run from `src/` and point this at the built worker. + workerPath: string = defaultWorkerPath +): Promise => { + const worker = new Worker(workerPath, { workerData: data }); + const pending = new Map(); + let nextId = 0; + let failure: Error | undefined; + + const fail = (error: Error): void => { + failure ??= error; + for (const { reject } of pending.values()) { + reject(failure); + } + pending.clear(); + }; + + const ready = new Promise( + (resolve, reject) => { + worker.on('message', (message: ServerBuildWorkerResponse) => { + if (message.type === 'ready') { + resolve(message.description); + return; + } + const entry = pending.get(message.id); + pending.delete(message.id); + entry?.resolve(message); + }); + worker.on('error', error => { + fail(normalizeEffectError(error)); + reject(failure); + }); + worker.on('exit', code => { + fail(new Error(`Server build worker exited with code ${code}`)); + reject(failure); + }); + } + ); + + const send = ( + request: ServerBuildWorkerRequest, + transfer: ArrayBuffer[] = [] + ): void => { + worker.postMessage(request, transfer); + }; + + // Import errors surface as worker 'error' events, an early exit as 'exit'. + const description = await ready; + + return { + description, + async handler(request) { + const id = nextId++; + const body = request.body + ? new Uint8Array(await request.arrayBuffer()) + : undefined; + // Relay the parent's release so the worker-side Request aborts too. + const onAbort = (): void => { + if (pending.has(id)) { + send({ type: 'abort', id }); + } + }; + const reply = await new Promise((resolve, reject) => { + if (failure) { + reject(failure); + return; + } + pending.set(id, { resolve, reject }); + request.signal.addEventListener('abort', onAbort, { once: true }); + send( + { + type: 'request', + id, + url: request.url, + method: request.method, + headers: headerEntries(request.headers), + body, + }, + body ? [body.buffer] : [] + ); + }).finally(() => request.signal.removeEventListener('abort', onAbort)); + if (!reply.ok) { + throw replyError(reply); + } + const { + status, + statusText, + headers, + body: responseBody, + } = reply.response; + return new Response(responseBody.byteLength ? responseBody : null, { + status, + statusText, + headers, + }); + }, + async close() { + fail(new Error('Server build worker was closed')); + await worker.terminate(); + }, + }; +}; diff --git a/src/server-build-worker-protocol.ts b/src/server-build-worker-protocol.ts new file mode 100644 index 00000000..e72dc22c --- /dev/null +++ b/src/server-build-worker-protocol.ts @@ -0,0 +1,64 @@ +// Messages between the build process and `server-build-worker`. + +/** Headers as a structured-cloneable list (the DOM lib's Headers is not iterable here). */ +export const headerEntries = (headers: Headers): [string, string][] => { + const entries: [string, string][] = []; + headers.forEach((value, key) => entries.push([key, value])); + return entries; +}; + +export type ServerBuildWorkerData = { + serverBuildPath: string; + mode: 'classic' | 'rsc'; +}; + +export type ServerBuildWorkerRequest = + | { + id: number; + type: 'request'; + url: string; + method: string; + headers: [string, string][]; + body?: Uint8Array; + } + /** The parent released the request before a reply arrived. */ + | { id: number; type: 'abort' }; + +export type SerializedResponse = { + status: number; + statusText: string; + headers: [string, string][]; + body: Uint8Array; +}; + +export type SerializedError = { + message: string; + stack?: string; + name?: string; +}; + +export type ServerBuildWorkerResponse = + /** Sent once the bundle is evaluated; carries the classic build description. */ + | { type: 'ready'; description?: ServerBuildDescription } + | { type: 'reply'; id: number; ok: true; response: SerializedResponse } + | { type: 'reply'; id: number; ok: false; error: SerializedError }; + +/** + * The parts of a classic React Router server build that build-time rendering + * reads, as plain data: route module exports are reported by presence only. + */ +export type ServerBuildDescription = { + prerender?: string[]; + routes: Record< + string, + { + id?: string; + parentId?: string; + path?: string; + index?: boolean; + caseSensitive?: boolean; + module: { default: boolean; ErrorBoundary: boolean; loader: boolean }; + } + >; + assets: { routes: Record }; +}; diff --git a/src/server-build-worker.ts b/src/server-build-worker.ts new file mode 100644 index 00000000..2581ffa4 --- /dev/null +++ b/src/server-build-worker.ts @@ -0,0 +1,164 @@ +// Worker entry: evaluates a built server bundle and serves requests to it for +// build-time rendering; see `startServerBuildWorker` for why this is a worker. +// `IS_RR_BUILD_REQUEST` is set for this module graph only. +import { parentPort, workerData } from 'node:worker_threads'; +import { pathToFileURL } from 'node:url'; +import { createRequestHandler, type ServerBuild } from 'react-router'; +import { PLUGIN_NAME } from './constants.js'; +import { resolveServerBuildModule } from './server-build-resolution.js'; +import { + headerEntries, + type ServerBuildWorkerData, + type ServerBuildWorkerRequest, + type ServerBuildWorkerResponse, + type ServerBuildDescription, + type SerializedError, +} from './server-build-worker-protocol.js'; + +const port = parentPort; +if (!port) { + throw new Error('server-build-worker must run as a worker thread'); +} + +const { serverBuildPath, mode } = workerData as ServerBuildWorkerData; +process.env.IS_RR_BUILD_REQUEST = 'yes'; + +const post = ( + message: ServerBuildWorkerResponse, + transfer: ArrayBuffer[] = [] +): void => { + port.postMessage(message, transfer); +}; + +const serializeError = (error: unknown): SerializedError => { + const value = error as { message?: unknown; stack?: unknown; name?: unknown }; + return { + message: String(value?.message ?? error), + stack: typeof value?.stack === 'string' ? value.stack : undefined, + name: typeof value?.name === 'string' ? value.name : undefined, + }; +}; + +const describeClassicBuild = (build: ServerBuild): ServerBuildDescription => ({ + prerender: build.prerender, + routes: Object.fromEntries( + Object.entries(build.routes).flatMap(([id, route]) => + route + ? [ + [ + id, + { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: { + default: route.module.default !== undefined, + ErrorBoundary: route.module.ErrorBoundary !== undefined, + loader: route.module.loader !== undefined, + }, + }, + ], + ] + : [] + ) + ), + assets: { + routes: Object.fromEntries( + Object.entries(build.assets.routes).flatMap(([id, route]) => + route ? [[id, { hasLoader: route.hasLoader }]] : [] + ) + ), + }, +}); + +const resolveRscFetch = ( + buildModule: unknown +): ((request: Request) => Promise) => { + const moduleRecord = buildModule as + | { default?: { fetch?: unknown; default?: { fetch?: unknown } } } + | undefined; + const fetch = + typeof moduleRecord?.default?.fetch === 'function' + ? moduleRecord.default.fetch + : typeof moduleRecord?.default?.default?.fetch === 'function' + ? moduleRecord.default.default.fetch + : null; + if (!fetch) { + throw new Error( + `[${PLUGIN_NAME}] RSC server build ${JSON.stringify( + serverBuildPath + )} must default-export an object with a fetch function.` + ); + } + return fetch as (request: Request) => Promise; +}; + +const buildModule = await import(pathToFileURL(serverBuildPath).href); +let description: ServerBuildDescription | undefined; +let handler: (request: Request) => Promise; +if (mode === 'classic') { + const build = await resolveServerBuildModule( + buildModule, + `Server build ${JSON.stringify(serverBuildPath)}` + ); + description = describeClassicBuild(build); + handler = createRequestHandler(build, 'production'); +} else { + handler = resolveRscFetch(buildModule); +} + +// One AbortController per in-flight request, so the Request the app receives +// is aborted when the parent releases it (`createBuildRequestEffect`) or once +// its response has been consumed here, mirroring the in-process contract. +const controllers = new Map(); +const release = (id: number): void => { + controllers.get(id)?.abort(); + controllers.delete(id); +}; + +port.on('message', async (message: ServerBuildWorkerRequest) => { + if (message.type === 'abort') { + release(message.id); + return; + } + const controller = new AbortController(); + controllers.set(message.id, controller); + try { + const response = await handler( + new Request(message.url, { + method: message.method, + headers: message.headers, + body: message.body, + signal: controller.signal, + }) + ); + const body = new Uint8Array(await response.arrayBuffer()); + release(message.id); + post( + { + type: 'reply', + id: message.id, + ok: true, + response: { + status: response.status, + statusText: response.statusText, + headers: headerEntries(response.headers), + body, + }, + }, + [body.buffer] + ); + } catch (error) { + release(message.id); + post({ + type: 'reply', + id: message.id, + ok: false, + error: serializeError(error), + }); + } +}); + +post({ type: 'ready', description }); diff --git a/src/server-utils.ts b/src/server-utils.ts index 4f9f50ac..cd3be9dc 100644 --- a/src/server-utils.ts +++ b/src/server-utils.ts @@ -88,8 +88,6 @@ export function generateServerBuild( `; } -export { resolveServerBuildModule }; - export function resolveReactRouterServerBuild( buildModule: unknown ): Promise { diff --git a/tests/react-router-framework/integration/helpers/rsbuild-config.ts b/tests/react-router-framework/integration/helpers/rsbuild-config.ts index 88c55ec8..88ad7b11 100644 --- a/tests/react-router-framework/integration/helpers/rsbuild-config.ts +++ b/tests/react-router-framework/integration/helpers/rsbuild-config.ts @@ -14,6 +14,8 @@ type RsbuildConfigBuildArgs = { type RsbuildConfigBaseArgs = { templateName?: TemplateName; + /** Rspack persistent cache, kept inside the fixture (fixtures share the template's node_modules). */ + buildCache?: boolean; base?: string; defineNodeEnv?: boolean; envPrefixes?: string[]; @@ -155,6 +157,11 @@ export const rsbuildConfig = { ] : []), ]), + ...configSection("performance", [ + ...(args.buildCache + ? [`buildCache: { cacheDirectory: "./.rspack-cache" },`] + : []), + ]), ...configSection("source", [ ...(args.defineNodeEnv || args.envPrefixes ? [ diff --git a/tests/react-router-framework/integration/helpers/rsbuild.ts b/tests/react-router-framework/integration/helpers/rsbuild.ts index 11e9f9c9..36d579d0 100644 --- a/tests/react-router-framework/integration/helpers/rsbuild.ts +++ b/tests/react-router-framework/integration/helpers/rsbuild.ts @@ -199,9 +199,12 @@ const colorEnv = { export const build = ({ cwd, env = {}, + timeout, }: { cwd: string; env?: Record; + /** Kill the build (SIGKILL) after this many ms; `status` is then `null`. */ + timeout?: number; }) => { let nodeBin = process.argv[0]; prepareFixtureProjectDependencies(cwd); @@ -213,6 +216,8 @@ export const build = ({ ...colorEnv, ...env, }), + timeout, + killSignal: "SIGKILL", }); }; @@ -226,6 +231,12 @@ const formatBuildFailure = (result: ReturnType) => { ].join("\n\n"); }; +/** Asserts a successful exit (a killed or hung build has `status: null`) and returns stdout. */ +export const expectBuildSucceeded = (result: ReturnType) => { + expect(result.status, formatBuildFailure(result)).toBe(0); + return result.stdout.toString("utf8"); +}; + export const reactRouterServe = async ({ cwd, port, @@ -509,7 +520,7 @@ export const test = base.extend({ let port = await getPort(); let cwd = await createProject(await files({ port })); let result = build({ cwd }); - expect(result.status, formatBuildFailure(result)).toBe(0); + expectBuildSucceeded(result); stop = await reactRouterServe({ cwd, port }); return { port, cwd }; }); @@ -522,7 +533,7 @@ export const test = base.extend({ let port = await getPort(); let cwd = await createProject(await files({ port }), template); let result = build({ cwd }); - expect(result.status, formatBuildFailure(result)).toBe(0); + expectBuildSucceeded(result); stop = await rsbuildPreview({ cwd, port }); return { port, cwd }; }); diff --git a/tests/react-router-framework/integration/spa-build-process-test.ts b/tests/react-router-framework/integration/spa-build-process-test.ts new file mode 100644 index 00000000..aae239ac --- /dev/null +++ b/tests/react-router-framework/integration/spa-build-process-test.ts @@ -0,0 +1,196 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; + +import { js } from "./helpers/create-fixture.js"; +import { + build, + createEditor, + createProject, + expectBuildSucceeded, + reactRouterConfig, + rsbuildConfig, +} from "./helpers/rsbuild.js"; + +// Build-time rendering (SPA-mode `index.html`, prerendering) evaluates the +// freshly built server bundle. These tests pin down properties of that step +// that only show up in real builds: +// - #135: the build process must exit even when the app's server graph opens +// a ref'd handle at module scope (the bundle runs in a terminated worker). +// - #136: with Rspack's persistent cache, a warm build must render against +// the assets it emitted, not the previous build's. + +// Generous: a hung build never exits, so any finite bound distinguishes. +const BUILD_TIMEOUT_MS = 180_000; + +// Node backs BroadcastChannel with a ref'd MessagePort, and it has been a +// global since v18, so `typeof BroadcastChannel !== "undefined"` guards pass +// at build time too. A common SPA pattern (cross-tab sign-out sync). Root is +// the only route whose module scope reaches the SPA server bundle. +const withModuleScopeHandle = async (cwd: string) => { + fs.writeFileSync( + path.join(cwd, "app/auth-channel.ts"), + 'export const channel = new BroadcastChannel("app-signout");\n', + ); + await createEditor(cwd)( + "app/root.tsx", + (contents) => `import "./auth-channel";\n${contents}`, + ); +}; + +const indexHtml = (cwd: string) => + fs.readFileSync(path.join(cwd, "build/client/index.html"), "utf8"); + +test.describe("build process with a module-scope handle in the server graph (#135)", () => { + test("ssr: false exits after generating index.html", async () => { + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ ssr: false }), + }); + await withModuleScopeHandle(cwd); + const stdout = expectBuildSucceeded( + build({ cwd, timeout: BUILD_TIMEOUT_MS }), + ); + expect(stdout).toContain("Removed server build"); + expect(indexHtml(cwd)).toContain(" { + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: true, + prerender: ["/"], + }), + }); + await withModuleScopeHandle(cwd); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expect(indexHtml(cwd)).toContain("Welcome to React Router"); + }); + + test("RSC prerender exits after writing the prerendered pages", async () => { + const cwd = await createProject( + { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/"], + }), + }, + "rsc-framework", + ); + await withModuleScopeHandle(cwd); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + expect(indexHtml(cwd)).toContain("Welcome to React Router"); + }); +}); + +test.describe("server build worker lifecycle", () => { + // Two prerendered routes whose root loader runs `loaderBody` per request. + const lifecycleFiles = (loaderBody: string) => ({ + "app/root.tsx": js` + import { appendFileSync } from "node:fs"; + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export function loader({ request }) { + const pathname = new URL(request.url).pathname; + ${loaderBody} + return null; + } + + export default function App() { + return ( + + + + + ); + } + `, + "app/routes/other.tsx": js` + export default function Other() { + return

Other

; + } + `, + }); + + test("aborts the Request the app receives once each render is released", async () => { + // In-process rendering aborted the request's signal after the handler + // settled (createBuildRequestEffect); the worker must relay that to the + // Request it constructs, or request-scoped cleanup never runs. + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: true, + prerender: ["/", "/other"], + }), + ...lifecycleFiles(` + request.signal.addEventListener("abort", () => { + appendFileSync("abort-log.txt", pathname + " "); + }); + `), + }); + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + const aborted = fs + .readFileSync(path.join(cwd, "abort-log.txt"), "utf8") + .trim() + .split(/\s+/); + expect(aborted).toContain("/"); + expect(aborted).toContain("/other"); + }); + + test("fails deterministically when the app exits the worker mid-build", async () => { + // The worker-side abort fires once the first response has been consumed; + // the app exiting there must fail the build with a clear error rather + // than leave it waiting. (The idle-exit case is covered by the direct + // worker test in tests/server-build-worker.test.ts.) + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ + ssr: true, + prerender: { paths: ["/", "/other"], concurrency: 1 }, + }), + ...lifecycleFiles(` + if (pathname === "/") { + request.signal.addEventListener("abort", () => process.exit(0)); + } + `), + }); + const result = build({ cwd, timeout: BUILD_TIMEOUT_MS }); + const stderr = result.stderr.toString("utf8"); + expect(result.signal, `build did not exit\n${stderr}`).toBeNull(); + expect(result.status).not.toBe(0); + expect(stderr).toContain("Server build worker exited with code 0"); + }); +}); + +test.describe("ssr: false with performance.buildCache (#136)", () => { + test("a warm build renders index.html against its own assets", async () => { + const cwd = await createProject({ + "react-router.config.ts": reactRouterConfig({ ssr: false }), + "rsbuild.config.ts": await rsbuildConfig.basic({ buildCache: true }), + }); + const referencedScripts = () => { + const urls = [ + ...indexHtml(cwd).matchAll(/["']\/(static\/js\/[^"']+\.js)["']/g), + ].map((match) => match[1]); + expect(urls.length).toBeGreaterThan(0); + return [...new Set(urls)]; + }; + const missing = (urls: string[]) => + urls.filter((url) => !fs.existsSync(path.join(cwd, "build/client", url))); + + // Cold build: fills the persistent cache. + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + const coldScripts = referencedScripts(); + expect(missing(coldScripts)).toEqual([]); + + // Change the root route so its (and the manifest's) content hash moves. + await createEditor(cwd)("app/root.tsx", (contents) => + contents.replace('', ''), + ); + fs.rmSync(path.join(cwd, "build"), { recursive: true, force: true }); + + // Warm build: the server-manifest module must not be served from cache. + expectBuildSucceeded(build({ cwd, timeout: BUILD_TIMEOUT_MS })); + const warmScripts = referencedScripts(); + expect(warmScripts).not.toEqual(coldScripts); + expect(missing(warmScripts)).toEqual([]); + }); +}); diff --git a/tests/rsc-prerender.test.ts b/tests/rsc-prerender.test.ts index 9a1bcbb0..63d22114 100644 --- a/tests/rsc-prerender.test.ts +++ b/tests/rsc-prerender.test.ts @@ -1,8 +1,8 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { createLogger } from '@rsbuild/core'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; import { SPA_FALLBACK_REQUEST_PATH, extractRscFlightData, @@ -13,6 +13,17 @@ import { runReactRouterRscPrerenderBuild, } from '../src/rsc-prerender'; +// The server bundle is evaluated in a worker shipped with `dist/`, which does +// not exist when unit tests run from source; the worker itself is exercised by +// the React Router integration suite (spa-build-process-test.ts). Stand in a +// handler that always fails so the error reporting path is what's under test. +rstest.mock('../src/server-build-worker-client', () => ({ + startServerBuildWorker: async () => ({ + handler: async () => new Response(null, { status: 500 }), + close: async () => {}, + }), +})); + const flightScript = (chunk: string) => ``; @@ -163,13 +174,6 @@ describe('runReactRouterRscPrerenderBuild', () => { ); try { - const serverDirectory = resolve(buildDirectory, 'server'); - await mkdir(serverDirectory); - await writeFile( - resolve(serverDirectory, 'index.js'), - 'export default { fetch: async () => new Response(null, { status: 500 }) };' - ); - await expect( runReactRouterRscPrerenderBuild({ api: { logger: createLogger({ level: 'silent' }) }, diff --git a/tests/server-build-worker.test.ts b/tests/server-build-worker.test.ts new file mode 100644 index 00000000..0c49a451 --- /dev/null +++ b/tests/server-build-worker.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; +import { startServerBuildWorker } from '../src/server-build-worker-client'; + +// Real worker threads against the built worker entry: the protocol has two +// sides, and parent-only mocks cannot see whether the Request the app receives +// is aborted or whether an idle worker exit is remembered. +// Missing (run `pnpm build`) surfaces as the Worker's own module-not-found. +const builtWorkerPath = resolve(__dirname, '../dist/server-build-worker.js'); + +// An RSC-shaped server build (`export default { fetch }`) is the smallest +// bundle the worker accepts; its routes exercise one lifecycle case each. +const serverBuildSource = ` +import { appendFileSync } from "node:fs"; + +export default { + async fetch(request) { + const url = new URL(request.url); + switch (url.pathname) { + case "/abort-log": + request.signal.addEventListener("abort", () => { + appendFileSync(url.searchParams.get("file"), "aborted\\n"); + }); + return new Response("logged"); + case "/wait-for-abort": + await new Promise(resolve => + request.signal.addEventListener("abort", resolve, { once: true }) + ); + return new Response("released", { status: 499 }); + case "/exit-soon": + setTimeout(() => process.exit(0), 20); + return new Response("bye"); + case "/throw": + throw new TypeError("boom"); + default: + return new Response("hello " + url.pathname, { + status: 201, + headers: { "x-echo": request.headers.get("x-in") ?? "" }, + }); + } + }, +}; +`; + +const settle = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe('server build worker', () => { + let directory: string; + let workers: Array<{ close(): Promise }> = []; + + const start = async () => { + const serverBuildPath = resolve(directory, 'server.mjs'); + await writeFile(serverBuildPath, serverBuildSource); + const worker = await startServerBuildWorker( + { serverBuildPath, mode: 'rsc' }, + builtWorkerPath + ); + workers.push(worker); + return worker; + }; + + beforeEach(async () => { + directory = await mkdtemp(resolve(tmpdir(), 'rsbuild-rr-worker-')); + }); + + afterEach(async () => { + await Promise.all(workers.map(worker => worker.close())); + workers = []; + await rm(directory, { recursive: true, force: true }); + }); + + it('proxies status, headers and body both ways', async () => { + const worker = await start(); + const response = await worker.handler( + new Request('http://localhost/greet', { headers: { 'x-in': 'ping' } }) + ); + expect(response.status).toBe(201); + expect(response.headers.get('x-echo')).toBe('ping'); + expect(await response.text()).toBe('hello /greet'); + }); + + it("aborts the app's Request once its response has been consumed", async () => { + const worker = await start(); + const log = resolve(directory, 'abort.log'); + const response = await worker.handler( + new Request(`http://localhost/abort-log?file=${encodeURIComponent(log)}`) + ); + expect(await response.text()).toBe('logged'); + // The worker releases the request before replying, so the app's cleanup + // has already run by the time the parent sees the response. + expect(readFileSync(log, 'utf8')).toBe('aborted\n'); + }); + + it("relays the parent's abort to an in-flight request", async () => { + const worker = await start(); + const controller = new AbortController(); + const pending = worker.handler( + new Request('http://localhost/wait-for-abort', { + signal: controller.signal, + }) + ); + await settle(50); + controller.abort(); + const response = await pending; + expect(response.status).toBe(499); + expect(await response.text()).toBe('released'); + }); + + it('rethrows app errors with their message and name', async () => { + const worker = await start(); + await expect( + worker.handler(new Request('http://localhost/throw')) + ).rejects.toMatchObject({ name: 'TypeError', message: 'boom' }); + }); + + it('rejects requests sent after the worker exited while idle', async () => { + const worker = await start(); + const response = await worker.handler( + new Request('http://localhost/exit-soon') + ); + expect(await response.text()).toBe('bye'); + // Nothing is pending when the worker exits; the exit must still be final. + await settle(300); + await expect( + worker.handler(new Request('http://localhost/after')) + ).rejects.toThrow('Server build worker exited with code 0'); + await expect( + worker.handler(new Request('http://localhost/again')) + ).rejects.toThrow('Server build worker exited with code 0'); + }); + + it('rejects requests after close()', async () => { + const worker = await start(); + await worker.close(); + await expect( + worker.handler(new Request('http://localhost/after-close')) + ).rejects.toThrow('Server build worker was closed'); + }); + + it('fails to start when the bundle cannot be imported', async () => { + await expect( + startServerBuildWorker( + { serverBuildPath: resolve(directory, 'missing.mjs'), mode: 'rsc' }, + builtWorkerPath + ) + ).rejects.toThrow(/Cannot find module|ERR_MODULE_NOT_FOUND/); + }); +});