diff --git a/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts b/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts index a95525a2af..ed83852f28 100644 --- a/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts +++ b/packages/react-on-rails-pro-node-renderer/src/shared/utils.ts @@ -8,6 +8,7 @@ import * as errorReporter from './errorReporter.js'; import { getConfig } from './configBuilder.js'; import log from './log.js'; import type { RenderResult } from '../worker/vm.js'; +import fileExistsAsync from './fileExistsAsync.js'; export const TRUNCATION_FILLER = '\n... TRUNCATED ...\n'; @@ -169,3 +170,29 @@ export function getAssetPath(bundleTimestamp: string | number, filename: string) const bundleDirectory = getBundleDirectory(bundleTimestamp); return path.join(bundleDirectory, filename); } + +export async function validateBundlesExist( + bundleTimestamp: string | number, + dependencyBundleTimestamps?: (string | number)[], +): Promise { + const missingBundles = ( + await Promise.all( + [...(dependencyBundleTimestamps ?? []), bundleTimestamp].map(async (timestamp) => { + const bundleFilePath = getRequestBundleFilePath(timestamp); + const fileExists = await fileExistsAsync(bundleFilePath); + return fileExists ? null : timestamp; + }), + ) + ).filter((timestamp) => timestamp !== null); + + if (missingBundles.length > 0) { + const missingBundlesText = missingBundles.length > 1 ? 'bundles' : 'bundle'; + log.info(`No saved ${missingBundlesText}: ${missingBundles.join(', ')}`); + return { + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + status: 410, + data: 'No bundle uploaded', + }; + } + return null; +} diff --git a/packages/react-on-rails-pro-node-renderer/src/worker.ts b/packages/react-on-rails-pro-node-renderer/src/worker.ts index ab87d57c4c..a7b862f0f4 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker.ts @@ -13,11 +13,21 @@ import log, { sharedLoggerOptions } from './shared/log.js'; import packageJson from './shared/packageJson.js'; import { buildConfig, Config, getConfig } from './shared/configBuilder.js'; import fileExistsAsync from './shared/fileExistsAsync.js'; -import type { FastifyInstance, FastifyReply, FastifyRequest } from './worker/types.js'; -import checkProtocolVersion from './worker/checkProtocolVersionHandler.js'; -import authenticate from './worker/authHandler.js'; -import { handleRenderRequest, type ProvidedNewBundle } from './worker/handleRenderRequest.js'; +import type { FastifyInstance, FastifyReply } from './worker/types.js'; +import { performRequestPrechecks } from './worker/requestPrechecks.js'; +import { type AuthBody, authenticate } from './worker/authHandler.js'; +import { + handleRenderRequest, + type ProvidedNewBundle, + handleNewBundlesProvided, +} from './worker/handleRenderRequest.js'; import handleGracefulShutdown from './worker/handleGracefulShutdown.js'; +import { + handleIncrementalRenderRequest, + type IncrementalRenderInitialRequest, + type IncrementalRenderSink, +} from './worker/handleIncrementalRenderRequest.js'; +import { handleIncrementalRenderStream } from './worker/handleIncrementalRenderStream.js'; import { errorResponseResult, formatExceptionMessage, @@ -163,41 +173,11 @@ export default function run(config: Partial) { }, }); - const isProtocolVersionMatch = async (req: FastifyRequest, res: FastifyReply) => { - // Check protocol version - const protocolVersionCheckingResult = checkProtocolVersion(req); - - if (typeof protocolVersionCheckingResult === 'object') { - await setResponse(protocolVersionCheckingResult, res); - return false; - } - - return true; - }; - - const isAuthenticated = async (req: FastifyRequest, res: FastifyReply) => { - // Authenticate Ruby client - const authResult = authenticate(req); - - if (typeof authResult === 'object') { - await setResponse(authResult, res); - return false; - } - - return true; - }; - - const requestPrechecks = async (req: FastifyRequest, res: FastifyReply) => { - if (!(await isProtocolVersionMatch(req, res))) { - return false; - } - - if (!(await isAuthenticated(req, res))) { - return false; - } - - return true; - }; + // Ensure NDJSON bodies are not buffered and are available as a stream immediately + app.addContentTypeParser('application/x-ndjson', (req, payload, done) => { + // Pass through the raw stream; the route will consume req.raw + done(null, payload); + }); // See https://github.com/shakacode/react_on_rails_pro/issues/119 for why // the digest is part of the request URL. Yes, it's not used here, but the @@ -212,7 +192,9 @@ export default function run(config: Partial) { // Can't infer from the route like Express can Params: { bundleTimestamp: string; renderRequestDigest: string }; }>('/bundles/:bundleTimestamp/render/:renderRequestDigest', async (req, res) => { - if (!(await requestPrechecks(req, res))) { + const precheckResult = performRequestPrechecks(req.body); + if (precheckResult) { + await setResponse(precheckResult, res); return; } @@ -254,7 +236,7 @@ export default function run(config: Partial) { providedNewBundles, assetsToCopy, }); - await setResponse(result, res); + await setResponse(result.response, res); } catch (err) { const exceptionMessage = formatExceptionMessage( renderingRequest, @@ -272,17 +254,129 @@ export default function run(config: Partial) { } }); + // Streaming NDJSON incremental render endpoint + app.post<{ + Params: { bundleTimestamp: string; renderRequestDigest: string }; + }>('/bundles/:bundleTimestamp/incremental-render/:renderRequestDigest', async (req, res) => { + const { bundleTimestamp } = req.params; + + // Stream parser state + let incrementalSink: IncrementalRenderSink | undefined; + + try { + // Handle the incremental render stream + await handleIncrementalRenderStream({ + request: req, + onRenderRequestReceived: async (obj: unknown) => { + // Build a temporary FastifyRequest shape for protocol/auth check + const tempReqBody = typeof obj === 'object' && obj !== null ? (obj as Record) : {}; + + // Perform request prechecks + const precheckResult = performRequestPrechecks(tempReqBody); + if (precheckResult) { + return { + response: precheckResult, + shouldContinue: false, + }; + } + + // Extract data for incremental render request + const dependencyBundleTimestamps = extractBodyArrayField( + tempReqBody as WithBodyArrayField, 'dependencyBundleTimestamps'>, + 'dependencyBundleTimestamps', + ); + + const initial: IncrementalRenderInitialRequest = { + firstRequestChunk: obj, + bundleTimestamp, + dependencyBundleTimestamps, + }; + + try { + const { response, sink } = await handleIncrementalRenderRequest(initial); + incrementalSink = sink; + + return { + response, + shouldContinue: !!incrementalSink, + }; + } catch (err) { + const errorResponse = errorResponseResult( + formatExceptionMessage( + 'IncrementalRender', + err, + 'Error while handling incremental render request', + ), + ); + return { + response: errorResponse, + shouldContinue: false, + }; + } + }, + + onUpdateReceived: async (obj: unknown) => { + if (!incrementalSink) { + log.error({ msg: 'Unexpected update chunk received after rendering was aborted', obj }); + return; + } + + try { + log.info(`Received a new update chunk ${JSON.stringify(obj)}`); + await incrementalSink.add(obj); + } catch (err) { + // Log error but don't stop processing + log.error({ err, msg: 'Error processing update chunk' }); + } + }, + + onResponseStart: async (response: ResponseResult) => { + await setResponse(response, res); + }, + + onRequestEnded: () => { + if (!incrementalSink) { + return; + } + + incrementalSink.handleRequestClosed(); + }, + }); + } catch (err) { + // If an error occurred during stream processing, send error response + const errorResponse = errorResponseResult( + formatExceptionMessage('IncrementalRender', err, 'Error while processing incremental render stream'), + ); + await setResponse(errorResponse, res); + } + }); + // There can be additional files that might be required at the runtime. // Since the remote renderer doesn't contain any assets, they must be uploaded manually. app.post<{ Body: WithBodyArrayField, 'targetBundles'>; }>('/upload-assets', async (req, res) => { - if (!(await requestPrechecks(req, res))) { + const precheckResult = performRequestPrechecks(req.body); + if (precheckResult) { + await setResponse(precheckResult, res); return; } let lockAcquired = false; let lockfileName: string | undefined; - const assets: Asset[] = Object.values(req.body).filter(isAsset); + const assets: Asset[] = []; + + // Extract bundles that start with 'bundle_' prefix + const bundles: Array<{ timestamp: string; bundle: Asset }> = []; + Object.entries(req.body).forEach(([key, value]) => { + if (isAsset(value)) { + if (key.startsWith('bundle_')) { + const timestamp = key.replace('bundle_', ''); + bundles.push({ timestamp, bundle: value }); + } else { + assets.push(value); + } + } + }); // Handle targetBundles as either a string or an array const targetBundles = extractBodyArrayField(req.body, 'targetBundles'); @@ -294,7 +388,9 @@ export default function run(config: Partial) { } const assetsDescription = JSON.stringify(assets.map((asset) => asset.filename)); - const taskDescription = `Uploading files ${assetsDescription} to bundle directories: ${targetBundles.join(', ')}`; + const bundlesDescription = + bundles.length > 0 ? ` and bundles ${JSON.stringify(bundles.map((b) => b.bundle.filename))}` : ''; + const taskDescription = `Uploading files ${assetsDescription}${bundlesDescription} to bundle directories: ${targetBundles.join(', ')}`; try { const { lockfileName: name, wasLockAcquired, errorMessage } = await lock('transferring-assets'); @@ -333,7 +429,24 @@ export default function run(config: Partial) { await Promise.all(assetCopyPromises); - // Delete assets from uploads directory + // Handle bundles using the existing logic from handleRenderRequest + if (bundles.length > 0) { + const providedNewBundles = bundles.map(({ timestamp, bundle }) => ({ + timestamp, + bundle, + })); + + // Use the existing bundle handling logic + // Note: handleNewBundlesProvided will handle deleting the uploaded bundle files + // Pass null for assetsToCopy since we handle assets separately in this endpoint + const bundleResult = await handleNewBundlesProvided('upload-assets', providedNewBundles, null); + if (bundleResult) { + await setResponse(bundleResult, res); + return; + } + } + + // Delete assets from uploads directory (bundles are already handled by handleNewBundlesProvided) await deleteUploadedAssets(assets); await setResponse( @@ -344,7 +457,7 @@ export default function run(config: Partial) { res, ); } catch (err) { - const msg = 'ERROR when trying to copy assets'; + const msg = 'ERROR when trying to copy assets and bundles'; const message = `${msg}. ${err}. Task: ${taskDescription}`; log.error({ msg, @@ -376,7 +489,9 @@ export default function run(config: Partial) { Querystring: { filename: string }; Body: WithBodyArrayField, 'targetBundles'>; }>('/asset-exists', async (req, res) => { - if (!(await isAuthenticated(req, res))) { + const authResult = authenticate(req.body as AuthBody); + if (authResult) { + await setResponse(authResult, res); return; } diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/authHandler.ts b/packages/react-on-rails-pro-node-renderer/src/worker/authHandler.ts index 6358dcb000..58ca5cc580 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker/authHandler.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker/authHandler.ts @@ -10,11 +10,15 @@ import { timingSafeEqual } from 'crypto'; import type { FastifyRequest } from './types.js'; import { getConfig } from '../shared/configBuilder.js'; -export default function authenticate(req: FastifyRequest) { +export interface AuthBody { + password?: string; +} + +export function authenticate(body: AuthBody) { const { password } = getConfig(); if (password) { - const reqPassword = (req.body as { password?: string }).password || ''; + const reqPassword = body.password || ''; // Use timing-safe comparison to prevent timing attacks // Both strings must be converted to buffers of the same length diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/checkProtocolVersionHandler.ts b/packages/react-on-rails-pro-node-renderer/src/worker/checkProtocolVersionHandler.ts index 1a0e6972e8..8737c14a59 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker/checkProtocolVersionHandler.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker/checkProtocolVersionHandler.ts @@ -2,7 +2,6 @@ * Logic for checking protocol version. * @module worker/checkProtocVersionHandler */ -import type { FastifyRequest } from './types.js'; import packageJson from '../shared/packageJson.js'; import log from '../shared/log.js'; @@ -35,14 +34,14 @@ function normalizeVersion(version: string): string { return normalized; } -interface RequestBody { +export interface RequestBody { protocolVersion?: string; gemVersion?: string; railsEnv?: string; } -export default function checkProtocolVersion(req: FastifyRequest) { - const { protocolVersion: reqProtocolVersion, gemVersion, railsEnv } = req.body as RequestBody; +export function checkProtocolVersion(body: RequestBody) { + const { protocolVersion: reqProtocolVersion, gemVersion, railsEnv } = body; // Check protocol version if (reqProtocolVersion !== packageJson.protocolVersion) { @@ -52,7 +51,7 @@ export default function checkProtocolVersion(req: FastifyRequest) { data: `Unsupported renderer protocol version ${ reqProtocolVersion ? `request protocol ${reqProtocolVersion}` - : `MISSING with body ${JSON.stringify(req.body)}` + : `MISSING with body ${JSON.stringify(body)}` } does not match installed renderer protocol ${packageJson.protocolVersion} for version ${packageJson.version}. Update either the renderer or the Rails server`, }; diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts b/packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts new file mode 100644 index 0000000000..0ebc8b7189 --- /dev/null +++ b/packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts @@ -0,0 +1,144 @@ +import type { ResponseResult } from '../shared/utils'; +import { handleRenderRequest } from './handleRenderRequest'; +import log from '../shared/log'; +import { getRequestBundleFilePath } from '../shared/utils'; + +export type IncrementalRenderSink = { + /** Called for every subsequent NDJSON object after the first one */ + add: (chunk: unknown) => Promise; + handleRequestClosed: () => void; +}; + +export type UpdateChunk = { + bundleTimestamp: string | number; + updateChunk: string; +}; + +function assertIsUpdateChunk(value: unknown): asserts value is UpdateChunk { + if ( + typeof value !== 'object' || + value === null || + !('bundleTimestamp' in value) || + !('updateChunk' in value) || + (typeof value.bundleTimestamp !== 'string' && typeof value.bundleTimestamp !== 'number') || + typeof value.updateChunk !== 'string' + ) { + throw new Error('Invalid incremental render chunk received, missing properties'); + } +} + +export type IncrementalRenderInitialRequest = { + firstRequestChunk: unknown; + bundleTimestamp: string | number; + dependencyBundleTimestamps?: string[] | number[]; +}; + +export type FirstIncrementalRenderRequestChunk = { + renderingRequest: string; + onRequestClosedUpdateChunk?: string; +}; + +function assertFirstIncrementalRenderRequestChunk( + chunk: unknown, +): asserts chunk is FirstIncrementalRenderRequestChunk { + if ( + typeof chunk !== 'object' || + chunk === null || + !('renderingRequest' in chunk) || + typeof chunk.renderingRequest !== 'string' || + // onRequestClosedUpdateChunk is an optional field + ('onRequestClosedUpdateChunk' in chunk && + chunk.onRequestClosedUpdateChunk && + typeof chunk.onRequestClosedUpdateChunk !== 'object') + ) { + throw new Error('Invalid first incremental render request chunk received, missing properties'); + } +} + +export type IncrementalRenderResult = { + response: ResponseResult; + sink?: IncrementalRenderSink; +}; + +/** + * Starts handling an incremental render request. This function: + * - Calls handleRenderRequest internally to handle all validation and VM execution + * - Returns the result from handleRenderRequest directly + * - Provides a sink for future incremental updates (to be implemented in next commit) + */ +export async function handleIncrementalRenderRequest( + initial: IncrementalRenderInitialRequest, +): Promise { + const { firstRequestChunk, bundleTimestamp, dependencyBundleTimestamps } = initial; + assertFirstIncrementalRenderRequestChunk(firstRequestChunk); + const { renderingRequest, onRequestClosedUpdateChunk } = firstRequestChunk; + + try { + // Call handleRenderRequest internally to handle all validation and VM execution + const { response, executionContext } = await handleRenderRequest({ + renderingRequest, + bundleTimestamp, + dependencyBundleTimestamps, + providedNewBundles: undefined, + assetsToCopy: undefined, + }); + + // If we don't get an execution context, it means there was an early error + // (e.g. bundle not found). In this case, the sink will be a no-op. + if (!executionContext) { + return { response }; + } + + // Return the result with a sink that uses the execution context + return { + response, + sink: { + add: async (chunk: unknown) => { + try { + assertIsUpdateChunk(chunk); + const bundlePath = getRequestBundleFilePath(chunk.bundleTimestamp); + await executionContext.runInVM(chunk.updateChunk, bundlePath).catch((err: unknown) => { + log.error({ msg: 'Error running incremental render chunk', err, chunk }); + }); + } catch (err) { + log.error({ msg: 'Invalid incremental render chunk', err, chunk }); + } + }, + handleRequestClosed: () => { + if (!onRequestClosedUpdateChunk) { + return; + } + + try { + assertIsUpdateChunk(onRequestClosedUpdateChunk); + const bundlePath = getRequestBundleFilePath(onRequestClosedUpdateChunk.bundleTimestamp); + executionContext + .runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath) + .catch((err: unknown) => { + log.error({ + msg: 'Error running onRequestClosedUpdateChunk', + err, + onRequestClosedUpdateChunk, + }); + }); + } catch (err) { + log.error({ msg: 'Invalid onRequestClosedUpdateChunk', err, onRequestClosedUpdateChunk }); + } + }, + }, + }; + } catch (error) { + // Handle any unexpected errors + const errorMessage = error instanceof Error ? error.message : String(error); + + return { + response: { + status: 500, + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + data: errorMessage, + }, + }; + } +} + +export type { ResponseResult }; diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderStream.ts b/packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderStream.ts new file mode 100644 index 0000000000..7882210118 --- /dev/null +++ b/packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderStream.ts @@ -0,0 +1,114 @@ +import { StringDecoder } from 'string_decoder'; +import type { ResponseResult } from '../shared/utils'; +import * as errorReporter from '../shared/errorReporter'; + +/** + * Result interface for render request callbacks + */ +export interface RenderRequestResult { + response: ResponseResult; + shouldContinue: boolean; +} + +/** + * Options interface for incremental render stream handler + */ +export interface IncrementalRenderStreamHandlerOptions { + request: { + raw: NodeJS.ReadableStream | { [Symbol.asyncIterator](): AsyncIterator }; + }; + onRenderRequestReceived: (renderRequest: unknown) => Promise | RenderRequestResult; + onResponseStart: (response: ResponseResult) => Promise | void; + onUpdateReceived: (updateData: unknown) => Promise | void; + onRequestEnded: () => Promise | void; +} + +/** + * Handles incremental rendering requests with streaming JSON data. + * The first object triggers rendering, subsequent objects provide incremental updates. + */ +export async function handleIncrementalRenderStream( + options: IncrementalRenderStreamHandlerOptions, +): Promise { + const { request, onRenderRequestReceived, onResponseStart, onUpdateReceived, onRequestEnded } = options; + + let hasReceivedFirstObject = false; + const decoder = new StringDecoder('utf8'); + let buffer = ''; + + try { + for await (const chunk of request.raw) { + const str = decoder.write(chunk); + buffer += str; + + // Process all complete JSON objects in the buffer + let boundary = buffer.indexOf('\n'); + while (boundary !== -1) { + const rawObject = buffer.slice(0, boundary).trim(); + buffer = buffer.slice(boundary + 1); + boundary = buffer.indexOf('\n'); + + if (rawObject) { + let parsed: unknown; + try { + parsed = JSON.parse(rawObject); + } catch (err) { + const errorMessage = `Invalid JSON chunk: ${err instanceof Error ? err.message : String(err)}`; + + if (!hasReceivedFirstObject) { + // Error in first chunk - throw error to stop processing + throw new Error(errorMessage); + } else { + // Error in subsequent chunks - log and report but continue processing + const reportedMessage = `JSON parsing error in update chunk: ${err instanceof Error ? err.message : String(err)}`; + console.error(reportedMessage); + errorReporter.message(reportedMessage); + // Skip this malformed chunk and continue with next ones + // eslint-disable-next-line no-continue + continue; + } + } + + if (!hasReceivedFirstObject) { + hasReceivedFirstObject = true; + try { + // eslint-disable-next-line no-await-in-loop + const result = await onRenderRequestReceived(parsed); + const { response, shouldContinue: continueFlag } = result; + + void onResponseStart(response); + + if (!continueFlag) { + return; + } + } catch (err) { + // Error in first chunk processing - throw error to stop processing + const error = err instanceof Error ? err : new Error(String(err)); + error.message = `Error processing initial render request: ${error.message}`; + throw error; + } + } else { + try { + // eslint-disable-next-line no-await-in-loop + await onUpdateReceived(parsed); + } catch (err) { + // Error in update chunk processing - log and report but continue processing + const errorMessage = `Error processing update chunk: ${err instanceof Error ? err.message : String(err)}`; + console.error(errorMessage); + errorReporter.message(errorMessage); + // Continue processing other chunks + } + } + } + } + } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + // Update the error message in place to retain the original stack trace, rather than creating a new error object + error.message = `Error while handling the request stream: ${error.message}`; + throw error; + } + + // Stream ended normally + void onRequestEnded(); +} diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts b/packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts index d4fc3fa36b..050701f1a3 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts @@ -23,10 +23,11 @@ import { isErrorRenderResult, getRequestBundleFilePath, deleteUploadedAssets, + validateBundlesExist, } from '../shared/utils.js'; import { getConfig } from '../shared/configBuilder.js'; import * as errorReporter from '../shared/errorReporter.js'; -import { buildVM, hasVMContextForBundle, runInVM } from './vm.js'; +import { buildExecutionContext, ExecutionContext, VMContextNotFoundError } from './vm.js'; export type ProvidedNewBundle = { timestamp: string | number; @@ -36,9 +37,10 @@ export type ProvidedNewBundle = { async function prepareResult( renderingRequest: string, bundleFilePathPerTimestamp: string, + executionContext: ExecutionContext, ): Promise { try { - const result = await runInVM(renderingRequest, bundleFilePathPerTimestamp, cluster); + const result = await executionContext.runInVM(renderingRequest, bundleFilePathPerTimestamp, cluster); let exceptionMessage = null; if (!result) { @@ -151,7 +153,7 @@ to ${bundleFilePathPerTimestamp})`, } } -async function handleNewBundlesProvided( +export async function handleNewBundlesProvided( renderingRequest: string, providedNewBundles: ProvidedNewBundle[], assetsToCopy: Asset[] | null | undefined, @@ -188,7 +190,7 @@ export async function handleRenderRequest({ dependencyBundleTimestamps?: string[] | number[]; providedNewBundles?: ProvidedNewBundle[] | null; assetsToCopy?: Asset[] | null; -}): Promise { +}): Promise<{ response: ResponseResult; executionContext?: ExecutionContext }> { try { // const bundleFilePathPerTimestamp = getRequestBundleFilePath(bundleTimestamp); const allBundleFilePaths = Array.from( @@ -200,52 +202,54 @@ export async function handleRenderRequest({ if (allBundleFilePaths.length > maxVMPoolSize) { return { - headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, - status: 410, - data: `Too many bundles uploaded. The maximum allowed is ${maxVMPoolSize}. Please reduce the number of bundles or increase maxVMPoolSize in your configuration.`, + response: { + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + status: 410, + data: `Too many bundles uploaded. The maximum allowed is ${maxVMPoolSize}. Please reduce the number of bundles or increase maxVMPoolSize in your configuration.`, + }, }; } - // If the current VM has the correct bundle and is ready - if (allBundleFilePaths.every((bundleFilePath) => hasVMContextForBundle(bundleFilePath))) { - return await prepareResult(renderingRequest, entryBundleFilePath); + try { + const executionContext = await buildExecutionContext(allBundleFilePaths, /* buildVmsIfNeeded */ false); + return { + response: await prepareResult(renderingRequest, entryBundleFilePath, executionContext), + executionContext, + }; + } catch (e) { + // Ignore VMContextNotFoundError, it means the bundle does not exist. + // The following code will handle this case. + if (!(e instanceof VMContextNotFoundError)) { + throw e; + } } // If gem has posted updated bundle: if (providedNewBundles && providedNewBundles.length > 0) { const result = await handleNewBundlesProvided(renderingRequest, providedNewBundles, assetsToCopy); if (result) { - return result; + return { response: result }; } } // Check if the bundle exists: - const missingBundles = ( - await Promise.all( - [...(dependencyBundleTimestamps ?? []), bundleTimestamp].map(async (timestamp) => { - const bundleFilePath = getRequestBundleFilePath(timestamp); - const fileExists = await fileExistsAsync(bundleFilePath); - return fileExists ? null : timestamp; - }), - ) - ).filter((timestamp) => timestamp !== null); - - if (missingBundles.length > 0) { - const missingBundlesText = missingBundles.length > 1 ? 'bundles' : 'bundle'; - log.info(`No saved ${missingBundlesText}: ${missingBundles.join(', ')}`); - return { - headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, - status: 410, - data: 'No bundle uploaded', - }; + const missingBundleError = await validateBundlesExist(bundleTimestamp, dependencyBundleTimestamps); + if (missingBundleError) { + return { response: missingBundleError }; } // The bundle exists, but the VM has not yet been created. // Another worker must have written it or it was saved during deployment. - log.info('Bundle %s exists. Building VM for worker %s.', entryBundleFilePath, workerIdLabel()); - await Promise.all(allBundleFilePaths.map((bundleFilePath) => buildVM(bundleFilePath))); - - return await prepareResult(renderingRequest, entryBundleFilePath); + log.info( + 'Bundle %s exists. Building ExecutionContext for worker %s.', + entryBundleFilePath, + workerIdLabel(), + ); + const executionContext = await buildExecutionContext(allBundleFilePaths, /* buildVmsIfNeeded */ true); + return { + response: await prepareResult(renderingRequest, entryBundleFilePath, executionContext), + executionContext, + }; } catch (error) { const msg = formatExceptionMessage( renderingRequest, diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/requestPrechecks.ts b/packages/react-on-rails-pro-node-renderer/src/worker/requestPrechecks.ts new file mode 100644 index 0000000000..b17f074e31 --- /dev/null +++ b/packages/react-on-rails-pro-node-renderer/src/worker/requestPrechecks.ts @@ -0,0 +1,27 @@ +/** + * Request prechecks logic that is independent of the HTTP server framework. + * @module worker/requestPrechecks + */ +import type { ResponseResult } from '../shared/utils'; +import { checkProtocolVersion, type RequestBody } from './checkProtocolVersionHandler'; +import { authenticate, type AuthBody } from './authHandler'; + +export interface RequestPrechecksBody extends RequestBody, AuthBody { + [key: string]: unknown; +} + +export function performRequestPrechecks(body: RequestPrechecksBody): ResponseResult | undefined { + // Check protocol version + const protocolVersionCheckingResult = checkProtocolVersion(body); + if (typeof protocolVersionCheckingResult === 'object') { + return protocolVersionCheckingResult; + } + + // Authenticate Ruby client + const authResult = authenticate(body); + if (typeof authResult === 'object') { + return authResult; + } + + return undefined; +} diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts b/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts index b8834e97a7..8dfac551bd 100644 --- a/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts +++ b/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts @@ -29,7 +29,7 @@ import * as errorReporter from '../shared/errorReporter.js'; const readFileAsync = promisify(fs.readFile); const writeFileAsync = promisify(fs.writeFile); -interface VMContext { +export interface VMContext { context: Context; sharedConsoleHistory: SharedConsoleHistory; lastUsed: number; // Track when this VM was last used @@ -39,7 +39,7 @@ interface VMContext { const vmContexts = new Map(); // Track VM creation promises to handle concurrent buildVM requests -const vmCreationPromises = new Map>(); +const vmCreationPromises = new Map>(); /** * Returns all bundle paths that have a VM context @@ -101,87 +101,17 @@ function manageVMPoolSize() { } } -/** - * - * @param renderingRequest JS Code to execute for SSR - * @param filePath - * @param vmCluster - */ -export async function runInVM( - renderingRequest: string, - filePath: string, - vmCluster?: typeof cluster, -): Promise { - const { serverBundleCachePath } = getConfig(); - - try { - // Wait for VM creation if it's in progress - if (vmCreationPromises.has(filePath)) { - await vmCreationPromises.get(filePath); - } - - // Get the correct VM context based on the provided bundle path - const vmContext = getVMContext(filePath); - - if (!vmContext) { - throw new Error(`No VM context found for bundle ${filePath}`); - } - - // Update last used timestamp - vmContext.lastUsed = Date.now(); - - const { context, sharedConsoleHistory } = vmContext; - - if (log.level === 'debug') { - // worker is nullable in the primary process - const workerId = vmCluster?.worker?.id; - log.debug(`worker ${workerId ? `${workerId} ` : ''}received render request for bundle ${filePath} with code -${smartTrim(renderingRequest)}`); - const debugOutputPathCode = path.join(serverBundleCachePath, 'code.js'); - log.debug(`Full code executed written to: ${debugOutputPathCode}`); - await writeFileAsync(debugOutputPathCode, renderingRequest); - } - - let result = sharedConsoleHistory.trackConsoleHistoryInRenderRequest(() => { - context.renderingRequest = renderingRequest; - try { - return vm.runInContext(renderingRequest, context) as RenderCodeResult; - } finally { - context.renderingRequest = undefined; - } - }); - - if (isReadableStream(result)) { - const newStreamAfterHandlingError = handleStreamError(result, (error) => { - const msg = formatExceptionMessage(renderingRequest, error, 'Error in a rendering stream'); - errorReporter.message(msg); - }); - return newStreamAfterHandlingError; - } - if (typeof result !== 'string') { - const objectResult = await result; - result = JSON.stringify(objectResult); - } - if (log.level === 'debug') { - log.debug(`result from JS: -${smartTrim(result)}`); - const debugOutputPathResult = path.join(serverBundleCachePath, 'result.json'); - log.debug(`Wrote result to file: ${debugOutputPathResult}`); - await writeFileAsync(debugOutputPathResult, result); - } - - return result; - } catch (exception) { - const exceptionMessage = formatExceptionMessage(renderingRequest, exception); - log.debug('Caught exception in rendering request: %s', exceptionMessage); - return Promise.resolve({ exceptionMessage }); +export class VMContextNotFoundError extends Error { + constructor(bundleFilePath: string) { + super(`VMContext not found for bundle: ${bundleFilePath}`); + this.name = 'VMContextNotFoundError'; } } -export async function buildVM(filePath: string) { +async function buildVM(filePath: string): Promise { // Return existing promise if VM is already being created if (vmCreationPromises.has(filePath)) { - return vmCreationPromises.get(filePath); + return vmCreationPromises.get(filePath) as Promise; } // Check if VM for this bundle already exists @@ -189,7 +119,7 @@ export async function buildVM(filePath: string) { if (vmContext) { // Update last used time when accessing existing VM vmContext.lastUsed = Date.now(); - return Promise.resolve(true); + return Promise.resolve(vmContext); } // Create a new promise for this VM creation @@ -200,12 +130,7 @@ export async function buildVM(filePath: string) { additionalContext !== null && additionalContext.constructor === Object; const sharedConsoleHistory = new SharedConsoleHistory(); - const runOnOtherBundle = async (bundleTimestamp: string | number, renderingRequest: string) => { - const bundlePath = getRequestBundleFilePath(bundleTimestamp); - return runInVM(renderingRequest, bundlePath, cluster); - }; - - const contextObject = { sharedConsoleHistory, runOnOtherBundle }; + const contextObject = { sharedConsoleHistory }; if (supportModules) { // IMPORTANT: When adding anything to this object, update: @@ -305,11 +230,12 @@ export async function buildVM(filePath: string) { } // Only now, after VM is fully initialized, store the context - vmContexts.set(filePath, { + const newVmContext: VMContext = { context, sharedConsoleHistory, lastUsed: Date.now(), - }); + }; + vmContexts.set(filePath, newVmContext); // Manage pool size after adding new VM manageVMPoolSize(); @@ -330,7 +256,7 @@ export async function buildVM(filePath: string) { ); } - return true; + return newVmContext; } catch (error) { log.error({ error }, 'Caught Error when creating context in buildVM'); errorReporter.error(error as Error); @@ -347,6 +273,120 @@ export async function buildVM(filePath: string) { return vmCreationPromise; } +async function getOrBuildVMContext(bundleFilePath: string, buildVmsIfNeeded: boolean): Promise { + const vmContext = getVMContext(bundleFilePath); + if (vmContext) { + return vmContext; + } + + const vmCreationPromise = vmCreationPromises.get(bundleFilePath); + if (vmCreationPromise) { + return vmCreationPromise; + } + + if (buildVmsIfNeeded) { + return buildVM(bundleFilePath); + } + + throw new VMContextNotFoundError(bundleFilePath); +} + +export type ExecutionContext = { + runInVM: ( + renderingRequest: string, + bundleFilePath: string, + vmCluster?: typeof cluster, + ) => Promise; + getVMContext: (bundleFilePath: string) => VMContext | undefined; +}; + +export async function buildExecutionContext( + bundlePaths: string[], + buildVmsIfNeeded: boolean, +): Promise { + const mapBundleFilePathToVMContext = new Map(); + await Promise.all( + bundlePaths.map(async (bundleFilePath) => { + const vmContext = await getOrBuildVMContext(bundleFilePath, buildVmsIfNeeded); + vmContext.lastUsed = Date.now(); + mapBundleFilePathToVMContext.set(bundleFilePath, vmContext); + }), + ); + const sharedExecutionContext = new Map(); + + const runInVM = async (renderingRequest: string, bundleFilePath: string, vmCluster?: typeof cluster) => { + try { + const { serverBundleCachePath } = getConfig(); + const vmContext = mapBundleFilePathToVMContext.get(bundleFilePath); + if (!vmContext) { + throw new VMContextNotFoundError(bundleFilePath); + } + + // Update last used timestamp + vmContext.lastUsed = Date.now(); + + const { context, sharedConsoleHistory } = vmContext; + + if (log.level === 'debug') { + // worker is nullable in the primary process + const workerId = vmCluster?.worker?.id; + log.debug(`worker ${workerId ? `${workerId} ` : ''}received render request for bundle ${bundleFilePath} with code + ${smartTrim(renderingRequest)}`); + const debugOutputPathCode = path.join(serverBundleCachePath, 'code.js'); + log.debug(`Full code executed written to: ${debugOutputPathCode}`); + await writeFileAsync(debugOutputPathCode, renderingRequest); + } + + let result = sharedConsoleHistory.trackConsoleHistoryInRenderRequest(() => { + context.renderingRequest = renderingRequest; + context.sharedExecutionContext = sharedExecutionContext; + context.runOnOtherBundle = (bundleTimestamp: string | number, newRenderingRequest: string) => { + const otherBundleFilePath = getRequestBundleFilePath(bundleTimestamp); + return runInVM(newRenderingRequest, otherBundleFilePath, vmCluster); + }; + + try { + return vm.runInContext(renderingRequest, context) as RenderCodeResult; + } finally { + context.renderingRequest = undefined; + context.sharedExecutionContext = undefined; + context.runOnOtherBundle = undefined; + } + }); + + if (isReadableStream(result)) { + const newStreamAfterHandlingError = handleStreamError(result, (error) => { + const msg = formatExceptionMessage(renderingRequest, error, 'Error in a rendering stream'); + errorReporter.message(msg); + }); + return newStreamAfterHandlingError; + } + if (typeof result !== 'string') { + const objectResult = await result; + result = JSON.stringify(objectResult); + } + if (log.level === 'debug' && result) { + log.debug(`result from JS: + ${smartTrim(result)}`); + const debugOutputPathResult = path.join(serverBundleCachePath, 'result.json'); + log.debug(`Wrote result to file: ${debugOutputPathResult}`); + await writeFileAsync(debugOutputPathResult, result); + } + + return result; + } catch (exception) { + const exceptionMessage = formatExceptionMessage(renderingRequest, exception); + log.debug('Caught exception in rendering request: %s', exceptionMessage); + return Promise.resolve({ exceptionMessage }); + } + }; + + return { + getVMContext: (bundleFilePath: string) => mapBundleFilePathToVMContext.get(bundleFilePath), + runInVM, + }; +} + /** @internal Used in tests */ export function resetVM() { // Clear all VM contexts diff --git a/packages/react-on-rails-pro-node-renderer/tests/fixtures/bundle-incremental.js b/packages/react-on-rails-pro-node-renderer/tests/fixtures/bundle-incremental.js new file mode 100644 index 0000000000..bc41ebb738 --- /dev/null +++ b/packages/react-on-rails-pro-node-renderer/tests/fixtures/bundle-incremental.js @@ -0,0 +1,41 @@ +const { PassThrough } = require('stream'); + +global.ReactOnRails = { + dummy: { html: 'Dummy Object' }, + + // Get or create stream + getStreamValues: function () { + if (!sharedExecutionContext.has('stream')) { + const stream = new PassThrough(); + sharedExecutionContext.set('stream', { stream }); + } + return sharedExecutionContext.get('stream').stream; + }, + + // Add value to stream + addStreamValue: function (value) { + if (!sharedExecutionContext.has('stream')) { + // Create the stream first if it doesn't exist + ReactOnRails.getStreamValues(); + } + const { stream } = sharedExecutionContext.get('stream'); + stream.write(value); + return value; + }, + + endStream: function () { + if (sharedExecutionContext.has('stream')) { + const { stream } = sharedExecutionContext.get('stream'); + stream.end(); + } + }, + + // Clear all stream values + clearStreamValues: function () { + if (sharedExecutionContext.has('stream')) { + const { stream } = sharedExecutionContext.get('stream'); + stream.destroy(); + sharedExecutionContext.delete('stream'); + } + }, +}; diff --git a/packages/react-on-rails-pro-node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js b/packages/react-on-rails-pro-node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js index 02d4de5dd7..4a7420982c 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js +++ b/packages/react-on-rails-pro-node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js @@ -8,17 +8,23 @@ rscBundleHash: '88888-test', } - if (typeof generateRSCPayload !== 'function') { - globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) { - const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters; - const propsString = JSON.stringify(props); - const newRenderingRequest = renderingRequest.replace(/\(\s*\)\s*$/, `('${componentName}', ${propsString})`); - return runOnOtherBundle(rscBundleHash, newRenderingRequest); - } + const runOnOtherBundle = globalThis.runOnOtherBundle; + const generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) { + const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters; + const propsString = JSON.stringify(props); + const newRenderingRequest = renderingRequest.replace(/\(\s*\)\s*$/, `('${componentName}', ${propsString})`); + return runOnOtherBundle(rscBundleHash, newRenderingRequest); } ReactOnRails.clearHydratedStores(); var usedProps = typeof props === 'undefined' ? {"helloWorldData":{"name":"Mr. Server Side Rendering","\u003cscript\u003ewindow.alert('xss1');\u003c/script\u003e":"\u003cscript\u003ewindow.alert(\"xss2\");\u003c/script\u003e"}} : props; + + if (ReactOnRails.isRSCBundle) { + var { props: propsWithAsyncProps, asyncPropManager } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps); + usedProps = propsWithAsyncProps; + sharedExecutionContext.set("asyncPropsManager", asyncPropManager); + } + return ReactOnRails[ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent']({ name: componentName, domNodeId: 'AsyncComponentsTreeForTesting-react-component-0', @@ -27,5 +33,6 @@ railsContext: railsContext, throwJsErrors: false, renderingReturnsPromises: true, + generateRSCPayload: typeof generateRSCPayload !== 'undefined' ? generateRSCPayload : undefined, }); })() diff --git a/packages/react-on-rails-pro-node-renderer/tests/fixtures/secondary-bundle-incremental.js b/packages/react-on-rails-pro-node-renderer/tests/fixtures/secondary-bundle-incremental.js new file mode 100644 index 0000000000..7a8637c4c8 --- /dev/null +++ b/packages/react-on-rails-pro-node-renderer/tests/fixtures/secondary-bundle-incremental.js @@ -0,0 +1,40 @@ +const { PassThrough } = require('stream'); + +global.ReactOnRails = { + dummy: { html: 'Dummy Object from secondary bundle' }, + + // Get or create stream + getStreamValues: function () { + if (!sharedExecutionContext.has('secondaryStream')) { + const stream = new PassThrough(); + sharedExecutionContext.set('secondaryStream', { stream }); + } + return sharedExecutionContext.get('secondaryStream').stream; + }, + + // Add value to stream + addStreamValue: function (value) { + if (!sharedExecutionContext.has('secondaryStream')) { + // Create the stream first if it doesn't exist + ReactOnRails.getStreamValues(); + } + const { stream } = sharedExecutionContext.get('secondaryStream'); + stream.write(value); + }, + + endStream: function () { + if (sharedExecutionContext.has('secondaryStream')) { + const { stream } = sharedExecutionContext.get('secondaryStream'); + stream.end(); + } + }, + + // Clear all stream values + clearStreamValues: function () { + if (sharedExecutionContext.has('secondaryStream')) { + const { stream } = sharedExecutionContext.get('secondaryStream'); + stream.destroy(); + sharedExecutionContext.delete('secondaryStream'); + } + }, +}; diff --git a/packages/react-on-rails-pro-node-renderer/tests/handleRenderRequest.test.ts b/packages/react-on-rails-pro-node-renderer/tests/handleRenderRequest.test.ts index 2557fa78d8..68c5c8230b 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/handleRenderRequest.test.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/handleRenderRequest.test.ts @@ -78,7 +78,7 @@ describe(testName, () => { ], }); - expect(result).toEqual(renderResult); + expect(result.response).toEqual(renderResult); expect( hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), ).toBeTruthy(); @@ -92,7 +92,7 @@ describe(testName, () => { bundleTimestamp: BUNDLE_TIMESTAMP, }); - expect(result).toEqual({ + expect(result.response).toEqual({ status: 410, headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, data: 'No bundle uploaded', @@ -108,7 +108,7 @@ describe(testName, () => { bundleTimestamp: BUNDLE_TIMESTAMP, }); - expect(result).toEqual(renderResult); + expect(result.response).toEqual(renderResult); }); test('If lockfile exists, and is stale', async () => { @@ -133,7 +133,7 @@ describe(testName, () => { ], }); - expect(result).toEqual(renderResult); + expect(result.response).toEqual(renderResult); expect( hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), ).toBeTruthy(); @@ -165,7 +165,7 @@ describe(testName, () => { ], }); - expect(result).toEqual(renderResult); + expect(result.response).toEqual(renderResult); expect( hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), ).toBeTruthy(); @@ -199,7 +199,7 @@ describe(testName, () => { ], }); - expect(result).toEqual(renderResult); + expect(result.response).toEqual(renderResult); // only the primary bundle should be in the VM context // The secondary bundle will be processed only if the rendering request requests it expect( @@ -254,7 +254,7 @@ describe(testName, () => { assetsToCopy: additionalAssets, }); - expect(result).toEqual(renderResult); + expect(result.response).toEqual(renderResult); // Only the primary bundle should be in the VM context // The secondary bundle will be processed only if the rendering request requests it @@ -310,7 +310,7 @@ describe(testName, () => { dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], }); - expect(result).toEqual({ + expect(result.response).toEqual({ status: 410, headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, data: 'No bundle uploaded', @@ -328,7 +328,7 @@ describe(testName, () => { dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], }); - expect(result).toEqual(renderResult); + expect(result.response).toEqual(renderResult); }); test('rendering request can call runOnOtherBundle', async () => { @@ -348,7 +348,7 @@ describe(testName, () => { dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], }); - expect(result).toEqual(renderResultFromBothBundles); + expect(result.response).toEqual(renderResultFromBothBundles); // Both bundles should be in the VM context expect( hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), @@ -370,7 +370,7 @@ describe(testName, () => { bundleTimestamp: BUNDLE_TIMESTAMP, }); - expect(result).toEqual({ + expect(result.response).toEqual({ status: 200, headers: { 'Cache-Control': 'public, max-age=31536000' }, data: renderingRequest, @@ -402,7 +402,7 @@ describe(testName, () => { bundleTimestamp: BUNDLE_TIMESTAMP, }); - expect(result).toEqual({ + expect(result.response).toEqual({ status: 200, headers: { 'Cache-Control': 'public, max-age=31536000' }, data: JSON.stringify('undefined'), @@ -420,7 +420,7 @@ describe(testName, () => { dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], }); - expect(result).toEqual({ + expect(result.response).toEqual({ status: 410, headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, data: 'No bundle uploaded', diff --git a/packages/react-on-rails-pro-node-renderer/tests/helper.ts b/packages/react-on-rails-pro-node-renderer/tests/helper.ts index 080577c1a5..86cfdd033f 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/helper.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/helper.ts @@ -4,7 +4,7 @@ import path from 'path'; import fsPromises from 'fs/promises'; import fs from 'fs'; import fsExtra from 'fs-extra'; -import { buildVM, resetVM } from '../src/worker/vm'; +import { buildExecutionContext, resetVM } from '../src/worker/vm'; import { buildConfig } from '../src/shared/configBuilder'; export const mkdirAsync = fsPromises.mkdir; @@ -27,6 +27,14 @@ export function getFixtureSecondaryBundle() { return path.resolve(__dirname, './fixtures/secondary-bundle.js'); } +export function getFixtureIncrementalBundle() { + return path.resolve(__dirname, './fixtures/bundle-incremental.js'); +} + +export function getFixtureIncrementalSecondaryBundle() { + return path.resolve(__dirname, './fixtures/secondary-bundle-incremental.js'); +} + export function getFixtureAsset() { return path.resolve(__dirname, `./fixtures/${ASSET_UPLOAD_FILE}`); } @@ -58,13 +66,37 @@ export function vmSecondaryBundlePath(testName: string) { } export async function createVmBundle(testName: string) { + // Build config with module support before creating VM bundle await safeCopyFileAsync(getFixtureBundle(), vmBundlePath(testName)); - return buildVM(vmBundlePath(testName)); + await buildExecutionContext([vmBundlePath(testName)], /* buildVmsIfNeeded */ true); } export async function createSecondaryVmBundle(testName: string) { + // Build config with module support before creating VM bundle await safeCopyFileAsync(getFixtureSecondaryBundle(), vmSecondaryBundlePath(testName)); - return buildVM(vmSecondaryBundlePath(testName)); + await buildExecutionContext([vmSecondaryBundlePath(testName)], /* buildVmsIfNeeded */ true); +} + +export async function createIncrementalVmBundle(testName: string) { + // Build config with module support before creating VM bundle + buildConfig({ + serverBundleCachePath: serverBundleCachePath(testName), + supportModules: true, + stubTimers: false, + }); + await safeCopyFileAsync(getFixtureIncrementalBundle(), vmBundlePath(testName)); + await buildExecutionContext([vmBundlePath(testName)], /* buildVmsIfNeeded */ true); +} + +export async function createIncrementalSecondaryVmBundle(testName: string) { + // Build config with module support before creating VM bundle + buildConfig({ + serverBundleCachePath: serverBundleCachePath(testName), + supportModules: true, + stubTimers: false, + }); + await safeCopyFileAsync(getFixtureIncrementalSecondaryBundle(), vmSecondaryBundlePath(testName)); + await buildExecutionContext([vmSecondaryBundlePath(testName)], /* buildVmsIfNeeded */ true); } export function lockfilePath(testName: string) { @@ -128,10 +160,12 @@ export async function createAsset(testName: string, bundleTimestamp: string) { ]); } -export async function resetForTest(testName: string) { +export async function resetForTest(testName: string, resetConfigs = true) { await fsExtra.emptyDir(serverBundleCachePath(testName)); resetVM(); - setConfig(testName); + if (resetConfigs) { + setConfig(testName); + } } export function readRenderingRequest(projectName: string, commit: string, requestDumpFileName: string) { @@ -144,4 +178,48 @@ export function readRenderingRequest(projectName: string, commit: string, reques return fs.readFileSync(path.resolve(__dirname, renderingRequestRelativePath), 'utf8'); } -setConfig('helper'); +/** + * Custom waitFor function that retries an expect statement until it passes or timeout is reached + * @param expectFn - Function containing Jest expect statements + * @param options - Configuration options + * @param options.timeout - Maximum time to wait in milliseconds (default: 1000) + * @param options.interval - Time between retries in milliseconds (default: 10) + * @param options.message - Custom error message when timeout is reached + */ +export const waitFor = async ( + expectFn: () => void, + options: { + timeout?: number; + interval?: number; + message?: string; + } = {}, +): Promise => { + const { timeout = 1000, interval = 10, message } = options; + const startTime = Date.now(); + let lastError: Error | null = null; + + while (Date.now() - startTime < timeout) { + try { + expectFn(); + // If we get here, the expect passed, so we can return + return; + } catch (error) { + lastError = error as Error; + // Expect failed, continue retrying + if (Date.now() - startTime >= timeout) { + // Timeout reached, re-throw the last error + throw error; + } + } + + // Wait before next retry + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, interval); + }); + } + + // Timeout reached, throw error with descriptive message + const defaultMessage = `Expect condition not met within ${timeout}ms`; + throw new Error(message || defaultMessage + (lastError ? `\nLast error: ${lastError.message}` : '')); +}; diff --git a/packages/react-on-rails-pro-node-renderer/tests/httpRequestUtils.ts b/packages/react-on-rails-pro-node-renderer/tests/httpRequestUtils.ts index e02f2fe06f..d6f6ff02de 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/httpRequestUtils.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/httpRequestUtils.ts @@ -19,19 +19,13 @@ type RequestOptions = { renderRscPayload: boolean; }; -export const createForm = ({ +export const createRenderingRequest = ({ project = 'spec-dummy', commit = '', props = {}, throwJsErrors = false, componentName = undefined, }: Partial = {}) => { - const form = new FormData(); - form.append('gemVersion', packageJson.version); - form.append('protocolVersion', packageJson.protocolVersion); - form.append('password', 'myPassword1'); - form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP); - let renderingRequestCode = readRenderingRequest( project, commit, @@ -45,6 +39,29 @@ export const createForm = ({ if (throwJsErrors) { renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true'); } + return renderingRequestCode; +}; + +export const createForm = ({ + project = 'spec-dummy', + commit = '', + props = {}, + throwJsErrors = false, + componentName = undefined, +}: Partial = {}) => { + const form = new FormData(); + form.append('gemVersion', packageJson.version); + form.append('protocolVersion', packageJson.protocolVersion); + form.append('password', 'myPassword1'); + form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP); + + const renderingRequestCode = createRenderingRequest({ + project, + commit, + props, + throwJsErrors, + componentName, + }); form.append('renderingRequest', renderingRequestCode); const testBundlesDirectory = path.join(__dirname, '../../../react_on_rails_pro/spec/dummy/ssr-generated'); @@ -76,7 +93,14 @@ export const createForm = ({ return form; }; -const getAppUrl = (app: ReturnType) => { +export const createUploadAssetsForm = (options: Partial = {}) => { + const requestForm = createForm(options); + requestForm.append('targetBundles[]', SERVER_BUNDLE_TIMESTAMP); + requestForm.append('targetBundles[]', RSC_BUNDLE_TIMESTAMP); + return requestForm; +}; + +export const getAppUrl = (app: ReturnType) => { const addresssInfo = app.server.address(); if (!addresssInfo) { throw new Error('The app has no address, ensure to run the app before running tests'); @@ -177,3 +201,66 @@ export const makeRequest = (app: ReturnType, options: Partial { + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout; + let cancelDataListener = () => {}; + if (timeout) { + timeoutId = setTimeout(() => { + cancelDataListener(); + reject(new Error(`Timeout after waiting for ${timeout}ms to get the next stream chunk`)); + }, timeout); + } + + const onData = (chunk: Buffer) => { + clearTimeout(timeoutId); + cancelDataListener(); + resolve(chunk.toString()); + }; + + const onError = (error: Error) => { + clearTimeout(timeoutId); + cancelDataListener(); + reject(error); + }; + + const onClose = () => { + reject(new Error('Stream Closed')); + }; + + cancelDataListener = () => { + stream.off('data', onData); + stream.off('error', onError); + stream.off('close', onClose); + }; + + stream.once('data', onData); + stream.once('error', onError); + if ('closed' in stream && stream.closed) { + onClose(); + } else { + stream.once('close', onClose); + } + }); +}; + +export const getNextChunk = async (stream: NodeJS.ReadableStream, options: { timeout?: number } = {}) => { + const receivedChunks: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + const chunk = await getNextChunkInternal(stream, options); + receivedChunks.push(chunk); + } catch (err) { + if (receivedChunks.length > 0) { + return receivedChunks.join(''); + } + throw err; + } + } +}; diff --git a/packages/react-on-rails-pro-node-renderer/tests/incrementalHtmlStreaming.test.ts b/packages/react-on-rails-pro-node-renderer/tests/incrementalHtmlStreaming.test.ts new file mode 100644 index 0000000000..750cdd2ded --- /dev/null +++ b/packages/react-on-rails-pro-node-renderer/tests/incrementalHtmlStreaming.test.ts @@ -0,0 +1,233 @@ +import http2 from 'http2'; +import buildApp from '../src/worker'; +import { createTestConfig } from './testingNodeRendererConfigs'; +import * as errorReporter from '../src/shared/errorReporter'; +import { + createRenderingRequest, + createUploadAssetsForm, + getAppUrl, + getNextChunk, + RSC_BUNDLE_TIMESTAMP, + SERVER_BUNDLE_TIMESTAMP, +} from './httpRequestUtils'; +import packageJson from '../src/shared/packageJson'; + +const { config } = createTestConfig('incrementalHtmlStreaming'); +const app = buildApp(config); + +beforeAll(async () => { + await app.ready(); + await app.listen({ port: 0 }); +}); + +afterAll(async () => { + await app.close(); +}); + +jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn()); + +const createHttpRequest = (bundleTimestamp: string = SERVER_BUNDLE_TIMESTAMP, pathSuffix = 'abc123') => { + const appUrl = getAppUrl(app); + const client = http2.connect(appUrl); + const request = client.request({ + ':method': 'POST', + ':path': `/bundles/${bundleTimestamp}/incremental-render/${pathSuffix}`, + 'content-type': 'application/x-ndjson', + }); + request.setEncoding('utf8'); + return { + request, + close: () => { + client.close(); + }, + }; +}; + +const createInitialObject = (bundleTimestamp: string = RSC_BUNDLE_TIMESTAMP, password = 'myPassword1') => ({ + gemVersion: packageJson.version, + protocolVersion: packageJson.protocolVersion, + password, + renderingRequest: createRenderingRequest({ componentName: 'AsyncPropsComponent' }), + onRequestClosedUpdateChunk: { + bundleTimestamp: RSC_BUNDLE_TIMESTAMP, + updateChunk: ` + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.endStream(); + })() + `, + }, + dependencyBundleTimestamps: [bundleTimestamp], +}); + +const makeRequest = async (options = {}) => { + const form = createUploadAssetsForm(options); + const appUrl = getAppUrl(app); + const client = http2.connect(appUrl); + const request = client.request({ + ':method': 'POST', + ':path': `/upload-assets`, + 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, + }); + request.setEncoding('utf8'); + + let status: number | undefined; + let body = ''; + + request.on('response', (headers) => { + status = headers[':status']; + }); + + request.on('data', (data: Buffer) => { + body += data.toString(); + }); + + form.pipe(request); + form.on('end', () => { + request.end(); + }); + + await new Promise((resolve, reject) => { + request.on('end', () => { + client.close(); + resolve(); + }); + request.on('error', (err) => { + client.close(); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + return { + status, + body, + }; +}; + +const waitForStatus = (request: http2.ClientHttp2Stream) => + new Promise((resolve) => { + request.on('response', (headers) => { + resolve(headers[':status']); + }); + }); + +it('uploads the bundles', async () => { + const { status, body } = await makeRequest(); + expect(body).toBe(''); + expect(status).toBe(200); +}); + +it('incremental render html', async () => { + const { status, body } = await makeRequest(); + expect(body).toBe(''); + expect(status).toBe(200); + + const { request, close } = createHttpRequest(); + const initialRequestObject = createInitialObject(); + request.write(`${JSON.stringify(initialRequestObject)}\n`); + + await expect(waitForStatus(request)).resolves.toBe(200); + await expect(getNextChunk(request)).resolves.toContain('AsyncPropsComponent is a renderFunction'); + + const updateChunk = { + bundleTimestamp: RSC_BUNDLE_TIMESTAMP, + updateChunk: ` + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.setProp("books", ["Tale of two towns", "Pro Git"]); + })() + `, + }; + request.write(`${JSON.stringify(updateChunk)}\n`); + await expect(getNextChunk(request)).resolves.toContain('Tale of two towns'); + + const updateChunk2 = { + bundleTimestamp: RSC_BUNDLE_TIMESTAMP, + updateChunk: ` + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.setProp("researches", ["AI effect on productivity", "Pro Git"]); + })() + `, + }; + request.write(`${JSON.stringify(updateChunk2)}\n`); + request.end(); + await expect(getNextChunk(request)).resolves.toContain('AI effect on productivity'); + + await expect(getNextChunk(request)).rejects.toThrow('Stream Closed'); + close(); +}); + +it('raises an error if a specific async prop is not sent', async () => { + const { status, body } = await makeRequest(); + expect(body).toBe(''); + expect(status).toBe(200); + + const { request, close } = createHttpRequest(); + const initialRequestObject = createInitialObject(); + request.write(`${JSON.stringify(initialRequestObject)}\n`); + + await expect(waitForStatus(request)).resolves.toBe(200); + await expect(getNextChunk(request)).resolves.toContain('AsyncPropsComponent is a renderFunction'); + + request.end(); + await expect(getNextChunk(request)).resolves.toContain( + 'The async prop \\"researches\\" is not received. Esnure to send the async prop from ruby side', + ); + + await expect(getNextChunk(request)).rejects.toThrow('Stream Closed'); + close(); +}); + +describe('concurrent incremental HTML streaming', () => { + it('handles multiple parallel requests without race conditions', async () => { + await makeRequest(); + + const numRequests = 5; + const requests = []; + + // Start all requests + for (let i = 0; i < numRequests; i += 1) { + const { request, close } = createHttpRequest(RSC_BUNDLE_TIMESTAMP, `concurrent-test-${i}`); + request.write(`${JSON.stringify(createInitialObject())}\n`); + requests.push({ request, close, id: i }); + } + + // Wait for all to connect and get initial chunks + await Promise.all(requests.map(({ request }) => waitForStatus(request))); + await Promise.all(requests.map(({ request }) => getNextChunk(request))); + + // Send update chunks to ALL requests before waiting for any responses + // If sequential: second request wouldn't process until first completes + // If concurrent: all process simultaneously + requests.forEach(({ request, id }) => { + request.write( + `${JSON.stringify({ + bundleTimestamp: RSC_BUNDLE_TIMESTAMP, + updateChunk: ` + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.setProp("books", ["Request-${id}-Book"]); + asyncPropsManager.setProp("researches", ["Request-${id}-Research"]); + })() + `, + })}\n`, + ); + request.end(); + }); + + // Now wait for all responses - they should all succeed + const results = await Promise.all( + requests.map(async ({ request, close, id }) => { + const chunk = await getNextChunk(request); + close(); + return { id, chunk }; + }), + ); + + results.forEach(({ id, chunk }) => { + expect(chunk).toContain(`Request-${id}-Book`); + expect(chunk).toContain(`Request-${id}-Research`); + }); + }); +}); diff --git a/packages/react-on-rails-pro-node-renderer/tests/incrementalRender.test.ts b/packages/react-on-rails-pro-node-renderer/tests/incrementalRender.test.ts new file mode 100644 index 0000000000..e85df9f637 --- /dev/null +++ b/packages/react-on-rails-pro-node-renderer/tests/incrementalRender.test.ts @@ -0,0 +1,875 @@ +import http from 'http'; +import fs from 'fs'; +import path from 'path'; +import worker, { disableHttp2 } from '../src/worker'; +import packageJson from '../src/shared/packageJson'; +import * as incremental from '../src/worker/handleIncrementalRenderRequest'; +import { + createVmBundle, + createIncrementalVmBundle, + createIncrementalSecondaryVmBundle, + BUNDLE_TIMESTAMP, + SECONDARY_BUNDLE_TIMESTAMP, + waitFor, + resetForTest, +} from './helper'; +import type { ResponseResult } from '../src/shared/utils'; + +// Disable HTTP/2 for testing like other tests do +disableHttp2(); + +describe('incremental render NDJSON endpoint', () => { + const TEST_NAME = 'incrementalRender'; + const BUNDLE_PATH = path.join(__dirname, 'tmp', TEST_NAME); + if (!fs.existsSync(BUNDLE_PATH)) { + fs.mkdirSync(BUNDLE_PATH, { recursive: true }); + } + + const app = worker({ + bundlePath: BUNDLE_PATH, + password: 'myPassword1', + // Keep HTTP logs quiet for tests + logHttpLevel: 'silent' as const, + supportModules: true, + }); + + // Helper functions to DRY up the tests + const getServerAddress = () => { + const addr = app.server.address(); + return { + host: typeof addr === 'object' && addr ? addr.address : '127.0.0.1', + port: typeof addr === 'object' && addr ? addr.port : 0, + }; + }; + + const createHttpRequest = (bundleTimestamp: string, pathSuffix = 'abc123') => { + const { host, port } = getServerAddress(); + const req = http.request({ + hostname: host, + port, + path: `/bundles/${bundleTimestamp}/incremental-render/${pathSuffix}`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-ndjson', + }, + }); + req.setNoDelay(true); + return req; + }; + + const createInitialObject = (bundleTimestamp: string, password = 'myPassword1') => ({ + gemVersion: packageJson.version, + protocolVersion: packageJson.protocolVersion, + password, + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [bundleTimestamp], + }); + + const createMockSink = () => { + const sinkAdd = jest.fn(); + const handleRequestClosed = jest.fn(); + + const sink: incremental.IncrementalRenderSink = { + add: sinkAdd, + handleRequestClosed, + }; + + return { sink, sinkAdd, handleRequestClosed }; + }; + + const createMockResponse = (data = 'mock response'): ResponseResult => ({ + status: 200, + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + data, + }); + + const createMockResult = (sink: incremental.IncrementalRenderSink, response?: ResponseResult) => { + const mockResponse = response || createMockResponse(); + return { + response: mockResponse, + sink, + } as incremental.IncrementalRenderResult; + }; + + const setupResponseHandler = (req: http.ClientRequest, captureData = false) => { + return new Promise<{ statusCode: number; data?: string }>((resolve, reject) => { + req.on('response', (res) => { + if (captureData) { + let data = ''; + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode || 0, data }); + }); + } else { + res.on('data', () => { + // Consume response data to prevent hanging + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode || 0 }); + }); + } + res.on('error', (e) => { + reject(e); + }); + }); + req.on('error', (e) => { + reject(e); + }); + }); + }; + + /** + * Helper function to create a basic test setup with mocked handleIncrementalRenderRequest + */ + const createBasicTestSetup = async () => { + await createVmBundle(TEST_NAME); + + const { sink, sinkAdd, handleRequestClosed } = createMockSink(); + const mockResponse = createMockResponse(); + const mockResult = createMockResult(sink, mockResponse); + + const handleSpy = jest + .spyOn(incremental, 'handleIncrementalRenderRequest') + .mockImplementation(() => Promise.resolve(mockResult)); + + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + return { + sink, + sinkAdd, + handleRequestClosed, + mockResponse, + mockResult, + handleSpy, + SERVER_BUNDLE_TIMESTAMP, + }; + }; + + /** + * Helper function to create a streaming test setup + */ + const createStreamingTestSetup = async () => { + await createVmBundle(TEST_NAME); + + const { Readable } = await import('stream'); + const responseStream = new Readable({ + read() { + // This is a readable stream that we can push to + }, + }); + + const sinkAdd = jest.fn(); + const handleRequestClosed = jest.fn(); + + const sink: incremental.IncrementalRenderSink = { + add: sinkAdd, + handleRequestClosed, + }; + + const mockResponse: ResponseResult = { + status: 200, + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + stream: responseStream, + }; + + const mockResult: incremental.IncrementalRenderResult = { + response: mockResponse, + sink, + }; + + const handleSpy = jest + .spyOn(incremental, 'handleIncrementalRenderRequest') + .mockImplementation(() => Promise.resolve(mockResult)); + + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + return { + responseStream, + sinkAdd, + handleRequestClosed, + sink, + mockResponse, + mockResult, + handleSpy, + SERVER_BUNDLE_TIMESTAMP, + }; + }; + + /** + * Helper function to send chunks and wait for processing + */ + const sendChunksAndWaitForProcessing = async ( + req: http.ClientRequest, + chunks: unknown[], + waitForCondition: (chunk: unknown, index: number) => Promise, + ) => { + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; + req.write(`${JSON.stringify(chunk)}\n`); + + // eslint-disable-next-line no-await-in-loop + await waitForCondition(chunk, i); + } + }; + + /** + * Helper function to create streaming response promise + */ + const createStreamingResponsePromise = (req: http.ClientRequest) => { + const receivedChunks: string[] = []; + + const promise = new Promise<{ statusCode: number; streamedData: string[] }>((resolve, reject) => { + req.on('response', (res) => { + res.on('data', (chunk: Buffer) => { + const chunkStr = chunk.toString(); + receivedChunks.push(chunkStr); + }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode || 0, + streamedData: [...receivedChunks], // Return a copy + }); + }); + res.on('error', (e) => { + reject(e); + }); + }); + req.on('error', (e) => { + reject(e); + }); + }); + + return { promise, receivedChunks }; + }; + + beforeEach(async () => { + await resetForTest(TEST_NAME, false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + beforeAll(async () => { + await app.ready(); + await app.listen({ port: 0 }); + }); + + afterAll(async () => { + await app.close(); + }); + + test('calls handleIncrementalRenderRequest immediately after first chunk and processes each subsequent chunk immediately', async () => { + const { sinkAdd, handleRequestClosed, handleSpy, SERVER_BUNDLE_TIMESTAMP } = await createBasicTestSetup(); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up promise to handle the response + const responsePromise = setupResponseHandler(req); + + // Write first object (headers, auth, and initial renderingRequest) + const initialObj = createInitialObject(SERVER_BUNDLE_TIMESTAMP); + req.write(`${JSON.stringify(initialObj)}\n`); + + // Wait for the server to process the first object + await waitFor(() => { + expect(handleSpy).toHaveBeenCalledTimes(1); + }); + + // Verify handleIncrementalRenderRequest was called immediately after first chunk + expect(handleSpy).toHaveBeenCalledTimes(1); + expect(sinkAdd).not.toHaveBeenCalled(); // No subsequent chunks processed yet + + // Send subsequent props chunks one by one and verify immediate processing + const chunksToSend = [{ a: 1 }, { b: 2 }, { c: 3 }]; + + await sendChunksAndWaitForProcessing(req, chunksToSend, async (chunk, index) => { + const expectedCallsBeforeWrite = index; + + // Verify state before writing this chunk + expect(sinkAdd).toHaveBeenCalledTimes(expectedCallsBeforeWrite); + + // Wait for the chunk to be processed + await waitFor(() => { + expect(sinkAdd).toHaveBeenCalledTimes(expectedCallsBeforeWrite + 1); + }); + + // Verify the chunk was processed immediately + expect(sinkAdd).toHaveBeenCalledTimes(expectedCallsBeforeWrite + 1); + expect(sinkAdd).toHaveBeenNthCalledWith(expectedCallsBeforeWrite + 1, chunk); + }); + + req.end(); + + // Wait for the request to complete + await responsePromise; + + // Final verification: all chunks were processed in the correct order + expect(handleSpy).toHaveBeenCalledTimes(1); + expect(sinkAdd.mock.calls).toEqual([[{ a: 1 }], [{ b: 2 }], [{ c: 3 }]]); + + // Verify handleRequestClosed was called when connection closed + await waitFor(() => { + expect(handleRequestClosed).toHaveBeenCalledTimes(1); + }); + }); + + test('returns 410 error when bundle is missing', async () => { + const MISSING_BUNDLE_TIMESTAMP = 'non-existent-bundle-123'; + + // Create the HTTP request with a non-existent bundle + const req = createHttpRequest(MISSING_BUNDLE_TIMESTAMP); + + // Set up promise to capture the response + const responsePromise = setupResponseHandler(req, true); + + // Write first object with auth data + const initialObj = createInitialObject(MISSING_BUNDLE_TIMESTAMP); + req.write(`${JSON.stringify(initialObj)}\n`); + req.end(); + + // Wait for the response + const response = await responsePromise; + + // Verify that we get a 410 error + expect(response.statusCode).toBe(410); + expect(response.data).toContain('No bundle uploaded'); + }); + + test('returns 400 error when first chunk contains malformed JSON', async () => { + // Create a bundle for this test + await createVmBundle(TEST_NAME); + + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up promise to capture the response + const responsePromise = setupResponseHandler(req, true); + + // Write malformed JSON as first chunk (missing closing brace) + const malformedJson = `{"gemVersion": "1.0.0", "protocolVersion": "2.0.0", "password": "myPassword1", "renderingRequest": "ReactOnRails.dummy", "dependencyBundleTimestamps": ["${SERVER_BUNDLE_TIMESTAMP}"]\n`; + req.write(malformedJson); + req.end(); + + // Wait for the response + const response = await responsePromise; + + // Verify that we get a 400 error due to malformed JSON + expect(response.statusCode).toBe(400); + expect(response.data).toContain('Invalid JSON chunk'); + }); + + test('continues processing when update chunk contains malformed JSON', async () => { + // Create a bundle for this test + await createVmBundle(TEST_NAME); + + const { sink, sinkAdd, handleRequestClosed } = createMockSink(); + + const mockResponse: ResponseResult = createMockResponse(); + + const mockResult: incremental.IncrementalRenderResult = createMockResult(sink, mockResponse); + + const resultPromise = Promise.resolve(mockResult); + const handleSpy = jest + .spyOn(incremental, 'handleIncrementalRenderRequest') + .mockImplementation(() => resultPromise); + + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up promise to handle the response + const responsePromise = setupResponseHandler(req); + + // Write first object (valid JSON) + const initialObj = createInitialObject(SERVER_BUNDLE_TIMESTAMP); + req.write(`${JSON.stringify(initialObj)}\n`); + + // Wait for the server to process the first object + await waitFor(() => { + expect(handleSpy).toHaveBeenCalledTimes(1); + }); + + // Send a valid chunk first + const validChunk = { a: 1 }; + req.write(`${JSON.stringify(validChunk)}\n`); + + // Wait for processing + await waitFor(() => { + expect(sinkAdd).toHaveBeenCalledWith({ a: 1 }); + }); + + // Verify the valid chunk was processed + expect(sinkAdd).toHaveBeenCalledWith({ a: 1 }); + + // Send a malformed JSON chunk + const malformedChunk = '{"invalid": json}\n'; + req.write(malformedChunk); + + // Send another valid chunk + const secondValidChunk = { d: 4 }; + req.write(`${JSON.stringify(secondValidChunk)}\n`); + + req.end(); + + // Wait for the request to complete + await responsePromise; + + // Verify that processing continued after the malformed chunk + // The malformed chunk should be skipped, but valid chunks should be processed + // Verify that the stream completed successfully + await waitFor(() => { + expect(sinkAdd.mock.calls).toEqual([[{ a: 1 }], [{ d: 4 }]]); + }); + + // Verify handleRequestClosed was called when connection closed + expect(handleRequestClosed).toHaveBeenCalledTimes(1); + }); + + test('handles empty lines gracefully in the stream', async () => { + // Create a bundle for this test + await createVmBundle(TEST_NAME); + + const { sink, sinkAdd, handleRequestClosed } = createMockSink(); + + const mockResponse: ResponseResult = createMockResponse(); + + const mockResult: incremental.IncrementalRenderResult = createMockResult(sink, mockResponse); + + const resultPromise = Promise.resolve(mockResult); + const handleSpy = jest + .spyOn(incremental, 'handleIncrementalRenderRequest') + .mockImplementation(() => resultPromise); + + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up promise to handle the response + const responsePromise = setupResponseHandler(req); + + // Write first object (valid JSON) + const initialObj = createInitialObject(SERVER_BUNDLE_TIMESTAMP); + req.write(`${JSON.stringify(initialObj)}\n`); + + // Wait for processing + await waitFor(() => { + expect(handleSpy).toHaveBeenCalledTimes(1); + }); + + // Send chunks with empty lines mixed in + const chunksToSend = [{ a: 1 }, { b: 2 }, { c: 3 }]; + + for (const chunk of chunksToSend) { + req.write(`${JSON.stringify(chunk)}\n`); + // eslint-disable-next-line no-await-in-loop + await waitFor(() => { + expect(sinkAdd).toHaveBeenCalledWith(chunk); + }); + } + + req.end(); + + // Wait for the request to complete + await responsePromise; + + // Verify that only valid JSON objects were processed + expect(handleSpy).toHaveBeenCalledTimes(1); + expect(sinkAdd.mock.calls).toEqual([[{ a: 1 }], [{ b: 2 }], [{ c: 3 }]]); + + // Verify handleRequestClosed was called when connection closed + await waitFor(() => { + expect(handleRequestClosed).toHaveBeenCalledTimes(1); + }); + }); + + test('throws error when first chunk processing fails (e.g., authentication)', async () => { + // Create a bundle for this test + await createVmBundle(TEST_NAME); + + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up promise to capture the response + const responsePromise = setupResponseHandler(req, true); + + // Write first object with invalid password (will cause authentication failure) + const initialObj = createInitialObject(SERVER_BUNDLE_TIMESTAMP, 'wrongPassword'); // Invalid password + req.write(`${JSON.stringify(initialObj)}\n`); + req.end(); + + // Wait for the response + const response = await responsePromise; + + // Verify that we get an authentication error (should be 400 or 401) + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(response.statusCode).toBeLessThan(500); + + // The response should contain an authentication error message + const responseText = response.data?.toLowerCase(); + expect( + responseText?.includes('password') || + responseText?.includes('auth') || + responseText?.includes('unauthorized'), + ).toBe(true); + }); + + test('streaming response - client receives all streamed chunks in real-time', async () => { + const responseChunks = [ + 'Hello from stream', + 'Chunk 1', + 'Chunk 2', + 'Chunk 3', + 'Chunk 4', + 'Chunk 5', + 'Goodbye from stream', + ]; + + const { responseStream, sinkAdd, handleRequestClosed, handleSpy, SERVER_BUNDLE_TIMESTAMP } = + await createStreamingTestSetup(); + + // write the response chunks to the stream + let sentChunkIndex = 0; + const intervalId = setInterval(() => { + if (sentChunkIndex < responseChunks.length) { + responseStream.push(responseChunks[sentChunkIndex] || null); + sentChunkIndex += 1; + } else { + responseStream.push(null); + clearInterval(intervalId); + } + }, 10); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up promise to capture the streaming response + const { promise } = createStreamingResponsePromise(req); + + // Write first object (valid JSON) + const initialObj = createInitialObject(SERVER_BUNDLE_TIMESTAMP); + req.write(`${JSON.stringify(initialObj)}\n`); + + // Wait for the server to process the first object and set up the response + await waitFor(() => { + expect(handleSpy).toHaveBeenCalledTimes(1); + }); + + // Verify handleIncrementalRenderRequest was called + expect(handleSpy).toHaveBeenCalledTimes(1); + + // Send a few chunks to trigger processing + const chunksToSend = [ + { type: 'update', data: 'chunk1' }, + { type: 'update', data: 'chunk2' }, + { type: 'update', data: 'chunk3' }, + ]; + + await sendChunksAndWaitForProcessing(req, chunksToSend, async (chunk) => { + await waitFor(() => { + expect(sinkAdd).toHaveBeenCalledWith(chunk); + }); + }); + + // End the request + req.end(); + + // Wait for the request to complete and capture the streaming response + const response = await promise; + + // Verify the response status + expect(response.statusCode).toBe(200); + + // Verify that we received all the streamed chunks + expect(response.streamedData).toHaveLength(responseChunks.length); + + // Verify that each chunk was received in order + responseChunks.forEach((expectedChunk, index) => { + const receivedChunk = response.streamedData[index]; + expect(receivedChunk).toEqual(expectedChunk); + }); + + // Verify that all request chunks were processed + expect(sinkAdd).toHaveBeenCalledTimes(chunksToSend.length); + chunksToSend.forEach((chunk, index) => { + expect(sinkAdd).toHaveBeenNthCalledWith(index + 1, chunk); + }); + + // Verify that the mock was called correctly + expect(handleSpy).toHaveBeenCalledTimes(1); + + // Verify handleRequestClosed was called when connection closed + expect(handleRequestClosed).toHaveBeenCalledTimes(1); + }); + + test('echo server - processes each chunk and immediately streams it back', async () => { + const { responseStream, sinkAdd, handleRequestClosed, handleSpy, SERVER_BUNDLE_TIMESTAMP } = + await createStreamingTestSetup(); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up promise to capture the streaming response + const { promise, receivedChunks } = createStreamingResponsePromise(req); + + // Write first object (valid JSON) + const initialObj = createInitialObject(SERVER_BUNDLE_TIMESTAMP); + req.write(`${JSON.stringify(initialObj)}\n`); + + // Wait for the server to process the first object and set up the response + await waitFor(() => { + expect(handleSpy).toHaveBeenCalledTimes(1); + }); + + // Verify handleIncrementalRenderRequest was called + expect(handleSpy).toHaveBeenCalledTimes(1); + + // Send chunks one by one and verify immediate processing and echoing + const chunksToSend = [ + { type: 'update', data: 'chunk1' }, + { type: 'update', data: 'chunk2' }, + { type: 'update', data: 'chunk3' }, + { type: 'update', data: 'chunk4' }, + ]; + + // Process each chunk and immediately echo it back + for (let i = 0; i < chunksToSend.length; i += 1) { + const chunk = chunksToSend[i]; + + // Send the chunk + req.write(`${JSON.stringify(chunk)}\n`); + + // Wait for the chunk to be processed + // eslint-disable-next-line no-await-in-loop + await waitFor(() => { + expect(sinkAdd).toHaveBeenCalledWith(chunk); + }); + + // Immediately echo the chunk back through the stream + const echoResponse = `processed ${JSON.stringify(chunk)}`; + responseStream.push(echoResponse); + + // Wait for the echo response to be received by the client + // eslint-disable-next-line no-await-in-loop + await waitFor(() => { + expect(receivedChunks[i]).toEqual(echoResponse); + }); + + // Wait a moment to ensure the echo is sent + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + + // End the stream to signal no more data + responseStream.push(null); + + // End the request + req.end(); + + // Wait for the request to complete and capture the streaming response + const response = await promise; + + // Verify the response status + expect(response.statusCode).toBe(200); + + // Verify that we received echo responses for each chunk + expect(response.streamedData).toHaveLength(chunksToSend.length); + + // Verify that each chunk was echoed back correctly + chunksToSend.forEach((chunk, index) => { + const expectedEcho = `processed ${JSON.stringify(chunk)}`; + const receivedEcho = response.streamedData[index]; + expect(receivedEcho).toEqual(expectedEcho); + }); + + // Verify that all request chunks were processed + expect(sinkAdd).toHaveBeenCalledTimes(chunksToSend.length); + chunksToSend.forEach((chunk, index) => { + expect(sinkAdd).toHaveBeenNthCalledWith(index + 1, chunk); + }); + + // Verify that the mock was called correctly + expect(handleSpy).toHaveBeenCalledTimes(1); + + // Verify handleRequestClosed was called when connection closed + expect(handleRequestClosed).toHaveBeenCalledTimes(1); + }); + + describe('incremental render update chunk functionality', () => { + test('basic incremental update - initial request gets value, update chunks set value', async () => { + await createIncrementalVmBundle(TEST_NAME); + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up response handling + const responsePromise = setupResponseHandler(req, true); + + // Send the initial object that gets the async value (should resolve after setAsyncValue is called) + const initialObject = { + ...createInitialObject(SERVER_BUNDLE_TIMESTAMP), + renderingRequest: 'ReactOnRails.getStreamValues()', + }; + req.write(`${JSON.stringify(initialObject)}\n`); + + // Send update chunks that set the async value + const updateChunk1 = { + bundleTimestamp: SERVER_BUNDLE_TIMESTAMP, + updateChunk: 'ReactOnRails.addStreamValue("first update");ReactOnRails.endStream();', + }; + req.write(`${JSON.stringify(updateChunk1)}\n`); + + // End the request + req.end(); + + // Wait for the response + const response = await responsePromise; + + // Verify the response + expect(response.statusCode).toBe(200); + expect(response.data).toBe('first update'); // Should resolve with the first setAsyncValue call + }); + + test('streaming functionality with incremental updates', async () => { + await createIncrementalVmBundle(TEST_NAME); + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up response handling to capture streaming data + const streamedData: string[] = []; + const responsePromise = new Promise<{ statusCode: number }>((resolve, reject) => { + req.on('response', (res) => { + res.on('data', (chunk: string) => { + streamedData.push(chunk.toString()); + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode || 0 }); + }); + res.on('error', reject); + }); + req.on('error', reject); + }); + + // Send the initial object that clears stream values and returns the stream + const initialObject = { + ...createInitialObject(SERVER_BUNDLE_TIMESTAMP), + renderingRequest: 'ReactOnRails.getStreamValues()', + }; + req.write(`${JSON.stringify(initialObject)}\n`); + + // Send update chunks that add stream values + const streamValues = ['stream1', 'stream2', 'stream3']; + for (const value of streamValues) { + const updateChunk = { + bundleTimestamp: SERVER_BUNDLE_TIMESTAMP, + updateChunk: `ReactOnRails.addStreamValue("${value}")`, + }; + req.write(`${JSON.stringify(updateChunk)}\n`); + } + + // End the stream to signal completion + const endStreamChunk = { + bundleTimestamp: SERVER_BUNDLE_TIMESTAMP, + updateChunk: 'ReactOnRails.endStream()', + }; + req.write(`${JSON.stringify(endStreamChunk)}\n`); + + // End the request + req.end(); + + // Wait for the response + const response = await responsePromise; + + // Verify the response + expect(response.statusCode).toBe(200); + // Since we're returning a stream, the response should indicate streaming + expect(streamedData.length).toBeGreaterThan(0); + }); + + test('complex multi-bundle streaming scenario', async () => { + await createIncrementalVmBundle(TEST_NAME); + await createIncrementalSecondaryVmBundle(TEST_NAME); + const SERVER_BUNDLE_TIMESTAMP = String(BUNDLE_TIMESTAMP); + const SECONDARY_BUNDLE_TIMESTAMP_STR = String(SECONDARY_BUNDLE_TIMESTAMP); + + // Create the HTTP request + const req = createHttpRequest(SERVER_BUNDLE_TIMESTAMP); + + // Set up response handling + const responsePromise = setupResponseHandler(req, true); + + // Send the initial object that sets up both bundles for streaming + const initialObject = { + ...createInitialObject(SERVER_BUNDLE_TIMESTAMP), + renderingRequest: ` + ReactOnRails.clearStreamValues(); + runOnOtherBundle(${SECONDARY_BUNDLE_TIMESTAMP}, 'ReactOnRails.clearStreamValues()').then(() => ({ + mainCleared: true, + secondaryCleared: true, + })); + `, + dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP_STR], + }; + req.write(`${JSON.stringify(initialObject)}\n`); + + // Send alternating updates to both bundles + const updates = [ + { bundleTimestamp: SERVER_BUNDLE_TIMESTAMP, updateChunk: 'ReactOnRails.addStreamValue("main1")' }, + { + bundleTimestamp: SECONDARY_BUNDLE_TIMESTAMP_STR, + updateChunk: 'ReactOnRails.addStreamValue("secondary1")', + }, + { bundleTimestamp: SERVER_BUNDLE_TIMESTAMP, updateChunk: 'ReactOnRails.addStreamValue("main2")' }, + { + bundleTimestamp: SECONDARY_BUNDLE_TIMESTAMP_STR, + updateChunk: 'ReactOnRails.addStreamValue("secondary2")', + }, + ]; + + for (const update of updates) { + req.write(`${JSON.stringify(update)}\n`); + } + + // Get final state from both bundles + const getFinalState = { + bundleTimestamp: SERVER_BUNDLE_TIMESTAMP, + updateChunk: ` + runOnOtherBundle(${SECONDARY_BUNDLE_TIMESTAMP}, 'ReactOnRails.getStreamValues()').then((secondaryValues) => ({ + mainValues: ReactOnRails.getStreamValues(), + secondaryValues: JSON.parse(secondaryValues), + })); + `, + }; + req.write(`${JSON.stringify(getFinalState)}\n`); + + // End the request + req.end(); + + // Wait for the response + const response = await responsePromise; + + // Verify the response + expect(response.statusCode).toBe(200); + const responseData = JSON.parse(response.data || '{}') as { + mainCleared: unknown; + secondaryCleared: unknown; + }; + expect(responseData.mainCleared).toBe(true); + expect(responseData.secondaryCleared).toBe(true); + }); + }); +}); diff --git a/packages/react-on-rails-pro-node-renderer/tests/serverRenderRSCReactComponent.test.js b/packages/react-on-rails-pro-node-renderer/tests/serverRenderRSCReactComponent.test.js index 547644fa7c..507ecaa6d6 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/serverRenderRSCReactComponent.test.js +++ b/packages/react-on-rails-pro-node-renderer/tests/serverRenderRSCReactComponent.test.js @@ -1,8 +1,9 @@ import path from 'path'; import fs from 'fs'; import { Readable } from 'stream'; -import { buildVM, getVMContext, resetVM } from '../src/worker/vm'; -import { getConfig } from '../src/shared/configBuilder'; +import { buildExecutionContext, resetVM } from '../src/worker/vm'; +import { buildConfig } from '../src/shared/configBuilder'; +import { serverBundleCachePath } from './helper'; const SimpleWorkingComponent = () => 'hello'; @@ -18,13 +19,14 @@ const ComponentWithAsyncError = async () => { }; describe('serverRenderRSCReactComponent', () => { + const testName = 'serverRenderRSCReactComponent'; let tempDir; let tempRscBundlePath; let tempManifestPath; beforeAll(async () => { - // Create temporary directory - tempDir = path.join(process.cwd(), 'tmp/node-renderer-bundles-test/testing-bundle'); + // Create temporary directory using helper to ensure unique path + tempDir = serverBundleCachePath(testName); fs.mkdirSync(tempDir, { recursive: true }); // Copy rsc-bundle.js to temp directory @@ -52,10 +54,12 @@ describe('serverRenderRSCReactComponent', () => { }); beforeEach(async () => { - const config = getConfig(); - config.supportModules = true; - config.maxVMPoolSize = 2; // Set a small pool size for testing - config.stubTimers = false; + buildConfig({ + serverBundleCachePath: tempDir, + supportModules: true, + stubTimers: false, + maxVMPoolSize: 2, + }); }); afterEach(async () => { @@ -65,9 +69,8 @@ describe('serverRenderRSCReactComponent', () => { // The serverRenderRSCReactComponent function should only be called when the bundle is compiled with the `react-server` condition. // Therefore, we cannot call it directly in the test files. Instead, we run the RSC bundle through the VM and call the method from there. const getReactOnRailsRSCObject = async () => { - // Use the copied rsc-bundle.js file from temp directory - await buildVM(tempRscBundlePath); - const vmContext = getVMContext(tempRscBundlePath); + const executionContext = await buildExecutionContext([tempRscBundlePath], /* buildVmsIfNeeded */ true); + const vmContext = executionContext.getVMContext(tempRscBundlePath); const { ReactOnRails, React } = vmContext.context; function SuspensedComponentWithAsyncError() { diff --git a/packages/react-on-rails-pro-node-renderer/tests/vm.test.ts b/packages/react-on-rails-pro-node-renderer/tests/vm.test.ts index 03e615ab81..8028726299 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/vm.test.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/vm.test.ts @@ -7,7 +7,7 @@ import { resetForTest, BUNDLE_TIMESTAMP, } from './helper'; -import { buildVM, hasVMContextForBundle, resetVM, runInVM, getVMContext } from '../src/worker/vm'; +import { buildExecutionContext, hasVMContextForBundle, resetVM } from '../src/worker/vm'; import { getConfig } from '../src/shared/configBuilder'; import { isErrorRenderResult } from '../src/shared/utils'; @@ -31,7 +31,10 @@ describe('buildVM and runInVM', () => { config.supportModules = false; await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); let result = await runInVM('typeof Buffer === "undefined"', uploadedBundlePathForTest()); expect(result).toBeTruthy(); @@ -45,7 +48,10 @@ describe('buildVM and runInVM', () => { config.supportModules = true; await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); let result = await runInVM('typeof Buffer !== "undefined"', uploadedBundlePathForTest()); expect(result).toBeTruthy(); @@ -58,7 +64,10 @@ describe('buildVM and runInVM', () => { describe('additionalContext', () => { test('not available if additionalContext not set', async () => { await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); const result = await runInVM('typeof testString === "undefined"', uploadedBundlePathForTest()); expect(result).toBeTruthy(); @@ -69,7 +78,10 @@ describe('buildVM and runInVM', () => { config.additionalContext = { testString: 'a string' }; await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); const result = await runInVM('typeof testString !== "undefined"', uploadedBundlePathForTest()); expect(result).toBeTruthy(); @@ -80,7 +92,10 @@ describe('buildVM and runInVM', () => { expect.assertions(14); await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); let result = await runInVM('ReactOnRails', uploadedBundlePathForTest()); expect(result).toEqual(JSON.stringify({ dummy: { html: 'Dummy Object' } })); @@ -128,7 +143,10 @@ describe('buildVM and runInVM', () => { test('VM security and captured exceptions', async () => { expect.assertions(1); await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); // Adopted form https://github.com/patriksimek/vm2/blob/master/test/tests.js: const result = await runInVM('process.exit()', uploadedBundlePathForTest()); expect( @@ -139,7 +157,10 @@ describe('buildVM and runInVM', () => { test('Captured exceptions for a long message', async () => { expect.assertions(4); await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); // Adopted form https://github.com/patriksimek/vm2/blob/master/test/tests.js: const code = `process.exit()${'\n// 1234567890123456789012345678901234567890'.repeat( 50, @@ -155,7 +176,10 @@ describe('buildVM and runInVM', () => { test('resetVM', async () => { expect.assertions(2); await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); const result = await runInVM('ReactOnRails', uploadedBundlePathForTest()); expect(result).toEqual(JSON.stringify({ dummy: { html: 'Dummy Object' } })); @@ -168,7 +192,10 @@ describe('buildVM and runInVM', () => { test('VM console history', async () => { expect.assertions(1); await createUploadedBundleForTest(); - await buildVM(uploadedBundlePathForTest()); + const { runInVM } = await buildExecutionContext( + [uploadedBundlePathForTest()], + /* buildVmsIfNeeded */ true, + ); const vmResult = await runInVM( 'console.log("Console message inside of VM") || console.history;', @@ -205,7 +232,7 @@ describe('buildVM and runInVM', () => { __dirname, './fixtures/projects/friendsandguests/1a7fe417/server-bundle.js', ); - await buildVM(serverBundlePath); + const { runInVM } = await buildExecutionContext([serverBundlePath], /* buildVmsIfNeeded */ true); // WelcomePage component: const welcomePageComponentRenderingRequest = readRenderingRequest( @@ -279,7 +306,7 @@ describe('buildVM and runInVM', () => { __dirname, './fixtures/projects/react-webpack-rails-tutorial/ec974491/server-bundle.js', ); - await buildVM(serverBundlePath); + const { runInVM } = await buildExecutionContext([serverBundlePath], /* buildVmsIfNeeded */ true); // NavigationBar component: const navigationBarComponentRenderingRequest = readRenderingRequest( @@ -324,7 +351,7 @@ describe('buildVM and runInVM', () => { __dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js', ); - await buildVM(serverBundlePath); + const { runInVM } = await buildExecutionContext([serverBundlePath], /* buildVmsIfNeeded */ true); // SignIn page with flash component: const signInPageWithFlashRenderingRequest = readRenderingRequest( @@ -382,7 +409,7 @@ describe('buildVM and runInVM', () => { __dirname, './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js', ); - await buildVM(serverBundlePath); + const { runInVM } = await buildExecutionContext([serverBundlePath], /* buildVmsIfNeeded */ true); // WelcomePage component: const reduxAppComponentRenderingRequest = readRenderingRequest( @@ -420,11 +447,11 @@ describe('buildVM and runInVM', () => { config.stubTimers = false; config.replayServerAsyncOperationLogs = replayServerAsyncOperationLogs; - await buildVM(serverBundlePath); + return buildExecutionContext([serverBundlePath], /* buildVmsIfNeeded */ true); }; test('console logs in sync and async server operations', async () => { - await prepareVM(true); + const { runInVM } = await prepareVM(true); const consoleLogsInAsyncServerRequestResult = (await runInVM( consoleLogsInAsyncServerRequest, serverBundlePath, @@ -445,7 +472,7 @@ describe('buildVM and runInVM', () => { }); test('console logs are not leaked to other requests', async () => { - await prepareVM(true); + const { runInVM } = await prepareVM(true); const otherRequestId = '9f3b7e12-5a8d-4c6f-b1e3-2d7f8a6c9e0b'; const otherconsoleLogsInAsyncServerRequest = consoleLogsInAsyncServerRequest.replace( requestId, @@ -477,7 +504,7 @@ describe('buildVM and runInVM', () => { }); test('if replayServerAsyncOperationLogs is false, only sync console logs are replayed', async () => { - await prepareVM(false); + const { runInVM } = await prepareVM(false); const consoleLogsInAsyncServerRequestResult = await runInVM( consoleLogsInAsyncServerRequest, serverBundlePath, @@ -498,7 +525,7 @@ describe('buildVM and runInVM', () => { }); test('console logs are not leaked to other requests when replayServerAsyncOperationLogs is false', async () => { - await prepareVM(false); + const { runInVM } = await prepareVM(false); const otherRequestId = '9f3b7e12-5a8d-4c6f-b1e3-2d7f8a6c9e0b'; const otherconsoleLogsInAsyncServerRequest = consoleLogsInAsyncServerRequest.replace( requestId, @@ -534,7 +561,7 @@ describe('buildVM and runInVM', () => { test('calling multiple buildVM in parallel creates the same VM context', async () => { const buildAndGetVmContext = async () => { - await prepareVM(true); + const { getVMContext } = await prepareVM(true); return getVMContext(serverBundlePath); }; @@ -544,7 +571,7 @@ describe('buildVM and runInVM', () => { test('running runInVM before buildVM', async () => { resetVM(); - void prepareVM(true); + const { runInVM } = await prepareVM(true); // If the bundle is parsed, ReactOnRails object will be globally available and has the serverRenderReactComponent method const ReactOnRails = await runInVM( 'typeof ReactOnRails !== "undefined" && ReactOnRails && typeof ReactOnRails.serverRenderReactComponent', @@ -555,17 +582,22 @@ describe('buildVM and runInVM', () => { test("running multiple buildVM in parallel doesn't cause runInVM to return partial results", async () => { resetVM(); - void Promise.all([prepareVM(true), prepareVM(true), prepareVM(true), prepareVM(true)]); + const [{ runInVM: runInVM1 }, { runInVM: runInVM2 }, { runInVM: runInVM3 }] = await Promise.all([ + prepareVM(true), + prepareVM(true), + prepareVM(true), + prepareVM(true), + ]); // If the bundle is parsed, ReactOnRails object will be globally available and has the serverRenderReactComponent method - const runCodeInVM = () => + const runCodeInVM = (runInVM: typeof runInVM1) => runInVM( 'typeof ReactOnRails !== "undefined" && ReactOnRails && typeof ReactOnRails.serverRenderReactComponent', serverBundlePath, ); const [runCodeInVM1, runCodeInVM2, runCodeInVM3] = await Promise.all([ - runCodeInVM(), - runCodeInVM(), - runCodeInVM(), + runCodeInVM(runInVM1), + runCodeInVM(runInVM2), + runCodeInVM(runInVM3), ]); expect(runCodeInVM1).toBe('function'); expect(runCodeInVM2).toBe('function'); @@ -598,9 +630,9 @@ describe('buildVM and runInVM', () => { const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js'); // Build VMs up to and beyond the pool limit - await buildVM(bundle1); - await buildVM(bundle2); - await buildVM(bundle3); + await buildExecutionContext([bundle1], /* buildVmsIfNeeded */ true); + await buildExecutionContext([bundle2], /* buildVmsIfNeeded */ true); + await buildExecutionContext([bundle3], /* buildVmsIfNeeded */ true); // Only the two most recently used bundles should have contexts expect(hasVMContextForBundle(bundle1)).toBeFalsy(); @@ -617,10 +649,10 @@ describe('buildVM and runInVM', () => { __dirname, './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js', ); - await buildVM(bundle1); - await buildVM(bundle2); - await buildVM(bundle2); - await buildVM(bundle2); + await buildExecutionContext([bundle1], /* buildVmsIfNeeded */ true); + await buildExecutionContext([bundle2], /* buildVmsIfNeeded */ true); + await buildExecutionContext([bundle2], /* buildVmsIfNeeded */ true); + await buildExecutionContext([bundle2], /* buildVmsIfNeeded */ true); expect(hasVMContextForBundle(bundle1)).toBeTruthy(); expect(hasVMContextForBundle(bundle2)).toBeTruthy(); @@ -638,8 +670,8 @@ describe('buildVM and runInVM', () => { const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js'); // Create initial VMs - await buildVM(bundle1); - await buildVM(bundle2); + await buildExecutionContext([bundle1], /* buildVmsIfNeeded */ true); + await buildExecutionContext([bundle2], /* buildVmsIfNeeded */ true); // Wait a bit to ensure timestamp difference await new Promise((resolve) => { @@ -647,10 +679,10 @@ describe('buildVM and runInVM', () => { }); // Access bundle1 again to update its timestamp - await buildVM(bundle1); + await buildExecutionContext([bundle1], /* buildVmsIfNeeded */ true); // Add a new VM - should remove bundle2 as it's the oldest - await buildVM(bundle3); + await buildExecutionContext([bundle3], /* buildVmsIfNeeded */ true); // Bundle1 should still exist as it was accessed more recently expect(hasVMContextForBundle(bundle1)).toBeTruthy(); @@ -670,8 +702,8 @@ describe('buildVM and runInVM', () => { const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js'); // Create initial VMs - await buildVM(bundle1); - await buildVM(bundle2); + const { runInVM } = await buildExecutionContext([bundle1], /* buildVmsIfNeeded */ true); + await buildExecutionContext([bundle2], /* buildVmsIfNeeded */ true); // Wait a bit to ensure timestamp difference await new Promise((resolve) => { @@ -682,7 +714,7 @@ describe('buildVM and runInVM', () => { await runInVM('1 + 1', bundle1); // Add a new VM - should remove bundle2 as it's the oldest - await buildVM(bundle3); + await buildExecutionContext([bundle3], /* buildVmsIfNeeded */ true); // Bundle1 should still exist as it was used more recently expect(hasVMContextForBundle(bundle1)).toBeTruthy(); @@ -697,16 +729,16 @@ describe('buildVM and runInVM', () => { ); // Build VM first time - await buildVM(bundle); + const { runInVM } = await buildExecutionContext([bundle], /* buildVmsIfNeeded */ true); // Set a variable in the VM context await runInVM('global.testVar = "test value"', bundle); // Build VM second time - should reuse existing context - await buildVM(bundle); + const { runInVM: runInVM2 } = await buildExecutionContext([bundle], /* buildVmsIfNeeded */ true); // Variable should still exist if context was reused - const result = await runInVM('global.testVar', bundle); + const result = await runInVM2('global.testVar', bundle); expect(result).toBe('test value'); }); }); diff --git a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts index 8a20ef0fbf..4d686b98cc 100644 --- a/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts +++ b/packages/react-on-rails-pro-node-renderer/tests/worker.test.ts @@ -1,5 +1,6 @@ import formAutoContent from 'form-auto-content'; import fs from 'fs'; +import path from 'path'; import querystring from 'querystring'; import { createReadStream } from 'fs-extra'; // eslint-disable-next-line import/no-relative-packages @@ -31,6 +32,15 @@ const railsEnv = 'test'; disableHttp2(); +// Helper to create worker with standard options +const createWorker = (options: Parameters[0] = {}) => + worker({ + serverBundleCachePath: serverBundleCachePathForTest(), + supportModules: true, + stubTimers: false, + ...options, + }); + describe('worker', () => { beforeEach(async () => { await resetForTest(testName); @@ -41,9 +51,7 @@ describe('worker', () => { }); test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest when bundle is provided and did not yet exist', async () => { - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const form = formAutoContent({ gemVersion, @@ -69,9 +77,7 @@ describe('worker', () => { }); test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest', async () => { - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const form = formAutoContent({ gemVersion, @@ -105,8 +111,7 @@ describe('worker', () => { async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'password', }); @@ -132,8 +137,7 @@ describe('worker', () => { async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'password', }); @@ -159,8 +163,7 @@ describe('worker', () => { async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'my_password', }); @@ -187,9 +190,7 @@ describe('worker', () => { async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const res = await app .inject() @@ -211,8 +212,7 @@ describe('worker', () => { const bundleHash = 'some-bundle-hash'; await createAsset(testName, bundleHash); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'my_password', }); @@ -237,8 +237,7 @@ describe('worker', () => { const bundleHash = 'some-bundle-hash'; await createAsset(testName, bundleHash); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'my_password', }); @@ -261,8 +260,7 @@ describe('worker', () => { test('post /asset-exists requires targetBundles (protocol version 2.0.0)', async () => { await createAsset(testName, String(BUNDLE_TIMESTAMP)); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'my_password', }); @@ -283,8 +281,7 @@ describe('worker', () => { test('post /upload-assets', async () => { const bundleHash = 'some-bundle-hash'; - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'my_password', }); @@ -307,8 +304,7 @@ describe('worker', () => { const bundleHash = 'some-bundle-hash'; const bundleHashOther = 'some-other-bundle-hash'; - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), + const app = createWorker({ password: 'my_password', }); @@ -334,9 +330,7 @@ describe('worker', () => { test('allows request when gem version matches package version', async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const res = await app .inject() @@ -355,9 +349,7 @@ describe('worker', () => { test('rejects request in development when gem version does not match', async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const res = await app .inject() @@ -379,9 +371,7 @@ describe('worker', () => { test('allows request in production when gem version does not match (with warning)', async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const res = await app .inject() @@ -400,9 +390,7 @@ describe('worker', () => { test('normalizes gem version with dot before prerelease (4.0.0.rc.1 == 4.0.0-rc.1)', async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); // If package version is 4.0.0, this tests that 4.0.0.rc.1 gets normalized to 4.0.0-rc.1 // For this test to work properly, we need to use a version that when normalized matches @@ -426,9 +414,7 @@ describe('worker', () => { test('normalizes gem version case-insensitively (4.0.0-RC.1 == 4.0.0-rc.1)', async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const gemVersionUpperCase = packageJson.version.toUpperCase(); @@ -449,9 +435,7 @@ describe('worker', () => { test('handles whitespace in gem version', async () => { await createVmBundleForTest(); - const app = worker({ - serverBundleCachePath: serverBundleCachePathForTest(), - }); + const app = createWorker(); const gemVersionWithWhitespace = ` ${packageJson.version} `; @@ -469,4 +453,618 @@ describe('worker', () => { expect(res.payload).toBe('{"html":"Dummy Object"}'); }); }); + + test('post /upload-assets with bundles and assets', async () => { + const bundleHash = 'some-bundle-hash'; + const secondaryBundleHash = 'secondary-bundle-hash'; + + const app = createWorker({ + password: 'my_password', + }); + + const form = formAutoContent({ + gemVersion, + protocolVersion, + password: 'my_password', + targetBundles: [bundleHash, secondaryBundleHash], + [`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()), + [`bundle_${secondaryBundleHash}`]: createReadStream(getFixtureSecondaryBundle()), + asset1: createReadStream(getFixtureAsset()), + asset2: createReadStream(getOtherFixtureAsset()), + }); + + const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end(); + expect(res.statusCode).toBe(200); + + // Verify assets are copied to both bundle directories + expect(fs.existsSync(assetPath(testName, bundleHash))).toBe(true); + expect(fs.existsSync(assetPathOther(testName, bundleHash))).toBe(true); + expect(fs.existsSync(assetPath(testName, secondaryBundleHash))).toBe(true); + expect(fs.existsSync(assetPathOther(testName, secondaryBundleHash))).toBe(true); + + // Verify bundles are placed in their correct directories + const bundle1Path = path.join(serverBundleCachePathForTest(), bundleHash, `${bundleHash}.js`); + const bundle2Path = path.join( + serverBundleCachePathForTest(), + secondaryBundleHash, + `${secondaryBundleHash}.js`, + ); + expect(fs.existsSync(bundle1Path)).toBe(true); + expect(fs.existsSync(bundle2Path)).toBe(true); + + // Verify the directory structure is correct + const bundle1Dir = path.join(serverBundleCachePathForTest(), bundleHash); + const bundle2Dir = path.join(serverBundleCachePathForTest(), secondaryBundleHash); + + // Each bundle directory should contain: 1 bundle file + 2 assets = 3 files total + const bundle1Files = fs.readdirSync(bundle1Dir); + const bundle2Files = fs.readdirSync(bundle2Dir); + + expect(bundle1Files).toHaveLength(3); // bundle file + 2 assets + expect(bundle2Files).toHaveLength(3); // bundle file + 2 assets + + // Verify the specific files exist in each directory + expect(bundle1Files).toContain(`${bundleHash}.js`); + expect(bundle1Files).toContain('loadable-stats.json'); + expect(bundle1Files).toContain('loadable-stats-other.json'); + + expect(bundle2Files).toContain(`${secondaryBundleHash}.js`); + expect(bundle2Files).toContain('loadable-stats.json'); + expect(bundle2Files).toContain('loadable-stats-other.json'); + }); + + test('post /upload-assets with only bundles (no assets)', async () => { + const bundleHash = 'bundle-only-hash'; + + const app = createWorker({ + password: 'my_password', + }); + + const form = formAutoContent({ + gemVersion, + protocolVersion, + password: 'my_password', + targetBundles: [bundleHash], + [`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()), + }); + + const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end(); + expect(res.statusCode).toBe(200); + + // Verify bundle is placed in the correct directory + const bundleFilePath = path.join(serverBundleCachePathForTest(), bundleHash, `${bundleHash}.js`); + expect(fs.existsSync(bundleFilePath)).toBe(true); + + // Verify the directory structure is correct + const bundleDir = path.join(serverBundleCachePathForTest(), bundleHash); + const files = fs.readdirSync(bundleDir); + + // Should only contain the bundle file, no assets + expect(files).toHaveLength(1); + expect(files[0]).toBe(`${bundleHash}.js`); + + // Verify no asset files were accidentally copied + expect(files).not.toContain('loadable-stats.json'); + expect(files).not.toContain('loadable-stats-other.json'); + }); + + test('post /upload-assets with no assets and no bundles (empty request)', async () => { + const bundleHash = 'empty-request-hash'; + + const app = createWorker({ + password: 'my_password', + }); + + const form = formAutoContent({ + gemVersion, + protocolVersion, + password: 'my_password', + targetBundles: [bundleHash], + // No assets or bundles uploaded + }); + + const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end(); + expect(res.statusCode).toBe(200); + + // Verify bundle directory is created + const bundleDirectory = path.join(serverBundleCachePathForTest(), bundleHash); + expect(fs.existsSync(bundleDirectory)).toBe(true); + + // Verify no files were copied (since none were uploaded) + const files = fs.readdirSync(bundleDirectory); + expect(files).toHaveLength(0); + }); + + test('post /upload-assets with duplicate bundle hash silently skips overwrite and returns 200', async () => { + const bundleHash = 'duplicate-bundle-hash'; + + const app = createWorker({ + password: 'my_password', + }); + + // First upload with bundle + const form1 = formAutoContent({ + gemVersion, + protocolVersion, + password: 'my_password', + targetBundles: [bundleHash], + [`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()), + }); + + const res1 = await app + .inject() + .post(`/upload-assets`) + .payload(form1.payload) + .headers(form1.headers) + .end(); + expect(res1.statusCode).toBe(200); + expect(res1.body).toBe(''); // Empty body on success + + // Verify first bundle was created correctly + const bundleDir = path.join(serverBundleCachePathForTest(), bundleHash); + expect(fs.existsSync(bundleDir)).toBe(true); + const bundleFilePath = path.join(bundleDir, `${bundleHash}.js`); + expect(fs.existsSync(bundleFilePath)).toBe(true); + + // Get file stats to verify it's the first bundle + const firstBundleStats = fs.statSync(bundleFilePath); + const firstBundleSize = firstBundleStats.size; + const firstBundleModTime = firstBundleStats.mtime.getTime(); + + // Second upload with the same bundle hash but different content + // This logs: "File exists when trying to overwrite bundle... Assuming bundle written by other thread" + // Then silently skips the overwrite operation and returns 200 success + const form2 = formAutoContent({ + gemVersion, + protocolVersion, + password: 'my_password', + targetBundles: [bundleHash], + [`bundle_${bundleHash}`]: createReadStream(getFixtureSecondaryBundle()), // Different content + }); + + const res2 = await app + .inject() + .post(`/upload-assets`) + .payload(form2.payload) + .headers(form2.headers) + .end(); + expect(res2.statusCode).toBe(200); // Still returns 200 success (no error) + expect(res2.body).toBe(''); // Empty body, no error message returned to client + + // Verify the bundle directory still exists + expect(fs.existsSync(bundleDir)).toBe(true); + + // Verify the bundle file still exists + expect(fs.existsSync(bundleFilePath)).toBe(true); + + // Verify the file was NOT overwritten (original bundle is preserved) + const secondBundleStats = fs.statSync(bundleFilePath); + const secondBundleSize = secondBundleStats.size; + const secondBundleModTime = secondBundleStats.mtime.getTime(); + + // The file size should be the same as the first upload (no overwrite occurred) + expect(secondBundleSize).toBe(firstBundleSize); + + // The modification time should be the same (file wasn't touched) + expect(secondBundleModTime).toBe(firstBundleModTime); + + // Verify the directory only contains one file (the original bundle) + const files = fs.readdirSync(bundleDir); + expect(files).toHaveLength(1); + expect(files[0]).toBe(`${bundleHash}.js`); + + // Verify the original content is preserved (62 bytes from bundle.js, not 84 from secondary-bundle.js) + expect(secondBundleSize).toBe(62); // Size of getFixtureBundle(), not getFixtureSecondaryBundle() + }); + + test('post /upload-assets with bundles placed in their own hash directories, not targetBundles directories', async () => { + const bundleHash = 'actual-bundle-hash'; + const targetBundleHash = 'target-bundle-hash'; // Different from actual bundle hash + + const app = createWorker({ + password: 'my_password', + }); + + const form = formAutoContent({ + gemVersion, + protocolVersion, + password: 'my_password', + targetBundles: [targetBundleHash], // This should NOT affect where the bundle is placed + [`bundle_${bundleHash}`]: createReadStream(getFixtureBundle()), // Bundle with its own hash + }); + + const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end(); + expect(res.statusCode).toBe(200); + + // Verify the bundle was placed in its OWN hash directory, not the targetBundles directory + const actualBundleDir = path.join(serverBundleCachePathForTest(), bundleHash); + const targetBundleDir = path.join(serverBundleCachePathForTest(), targetBundleHash); + + // Bundle should exist in its own hash directory + expect(fs.existsSync(actualBundleDir)).toBe(true); + const bundleFilePath = path.join(actualBundleDir, `${bundleHash}.js`); + expect(fs.existsSync(bundleFilePath)).toBe(true); + + // Target bundle directory should also exist (created for assets) + expect(fs.existsSync(targetBundleDir)).toBe(true); + + // But the bundle file should NOT be in the target bundle directory + const targetBundleFilePath = path.join(targetBundleDir, `${bundleHash}.js`); + expect(fs.existsSync(targetBundleFilePath)).toBe(false); + + // Verify the bundle is in the correct location with correct name + const files = fs.readdirSync(actualBundleDir); + expect(files).toHaveLength(1); + expect(files[0]).toBe(`${bundleHash}.js`); + + // Verify the target bundle directory is empty (no assets uploaded) + const targetFiles = fs.readdirSync(targetBundleDir); + expect(targetFiles).toHaveLength(0); + }); + + // Incremental Render Endpoint Tests + describe('incremental render endpoint', () => { + // Helper functions to reduce code duplication + const createWorkerApp = (password = 'my_password') => + createWorker({ + password, + }); + + const uploadBundle = async ( + app: ReturnType, + bundleTimestamp = BUNDLE_TIMESTAMP, + password = 'my_password', + ) => { + const uploadForm = formAutoContent({ + gemVersion, + protocolVersion, + password, + targetBundles: [String(bundleTimestamp)], + [`bundle_${bundleTimestamp}`]: createReadStream(getFixtureBundle()), + }); + + const uploadRes = await app + .inject() + .post('/upload-assets') + .payload(uploadForm.payload) + .headers(uploadForm.headers) + .end(); + + expect(uploadRes.statusCode).toBe(200); + return uploadRes; + }; + + const uploadMultipleBundles = async ( + app: ReturnType, + bundleTimestamps: number[], + password = 'my_password', + ) => { + const uploadForm = formAutoContent({ + gemVersion, + protocolVersion, + password, + targetBundles: bundleTimestamps.map(String), + [`bundle_${bundleTimestamps[0]}`]: createReadStream(getFixtureBundle()), + [`bundle_${bundleTimestamps[1]}`]: createReadStream(getFixtureSecondaryBundle()), + }); + + const uploadRes = await app + .inject() + .post('/upload-assets') + .payload(uploadForm.payload) + .headers(uploadForm.headers) + .end(); + + expect(uploadRes.statusCode).toBe(200); + return uploadRes; + }; + + const createNDJSONPayload = (data: Record) => `${JSON.stringify(data)}\n`; + + const callIncrementalRender = async ( + app: ReturnType, + bundleTimestamp: number, + renderRequestDigest: string, + payload: Record, + expectedStatus = 200, + ) => { + const res = await app + .inject() + .post(`/bundles/${bundleTimestamp}/incremental-render/${renderRequestDigest}`) + .payload(createNDJSONPayload(payload)) + .headers({ + 'Content-Type': 'application/x-ndjson', + }) + .end(); + + expect(res.statusCode).toBe(expectedStatus); + return res; + }; + + test('renders successfully when bundle and assets are pre-uploaded', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + const payload = { + gemVersion, + protocolVersion, + password: 'my_password', + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + ); + + expect(res.headers['cache-control']).toBe('public, max-age=31536000'); + expect(res.payload).toBe('{"html":"Dummy Object"}'); + }); + + test('renders successfully with multiple dependency bundles', async () => { + const app = createWorkerApp(); + await uploadMultipleBundles(app, [BUNDLE_TIMESTAMP, SECONDARY_BUNDLE_TIMESTAMP]); + + // Test that we can render from the main bundle and call code from the secondary bundle + const payload = { + gemVersion, + protocolVersion, + password: 'my_password', + renderingRequest: ` + runOnOtherBundle(${SECONDARY_BUNDLE_TIMESTAMP}, 'ReactOnRails.dummy').then((secondaryBundleResult) => ({ + mainBundleResult: ReactOnRails.dummy, + secondaryBundleResult: JSON.parse(secondaryBundleResult), + })); + `, + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP), String(SECONDARY_BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + ); + + expect(res.headers['cache-control']).toBe('public, max-age=31536000'); + expect(res.payload).toBe( + '{"mainBundleResult":{"html":"Dummy Object"},"secondaryBundleResult":{"html":"Dummy Object from secondary bundle"}}', + ); + }); + + test('fails when bundle is not pre-uploaded', async () => { + const app = createWorkerApp(); + + const payload = { + gemVersion, + protocolVersion, + password: 'my_password', + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + 410, + ); + + expect(res.payload).toContain('No bundle uploaded'); + }); + + test('fails with invalid JSON in first chunk', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/incremental-render/d41d8cd98f00b204e9800998ecf8427e`) + .payload('invalid json\n') + .headers({ + 'Content-Type': 'application/x-ndjson', + }) + .end(); + + expect(res.statusCode).toBe(400); + expect(res.payload).toContain('Invalid JSON chunk'); + }); + + test('fails with missing required fields in first chunk', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + const incompletePayload = { + gemVersion, + protocolVersion, + password: 'my_password', + // Missing renderingRequest + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + incompletePayload, + 400, + ); + + expect(res.payload).toContain('Invalid first incremental render request chunk received'); + }); + + test('fails when password is missing', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + const payload = { + gemVersion, + protocolVersion, + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + 401, + ); + + expect(res.payload).toBe('Wrong password'); + }); + + test('fails when password is wrong', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + const payload = { + gemVersion, + protocolVersion, + password: 'wrong_password', + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + 401, + ); + + expect(res.payload).toBe('Wrong password'); + }); + + test('succeeds when password is required and correct password is provided', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + const payload = { + gemVersion, + protocolVersion, + password: 'my_password', + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + ); + + expect(res.statusCode).toBe(200); + expect(res.headers['cache-control']).toBe('public, max-age=31536000'); + expect(res.payload).toBe('{"html":"Dummy Object"}'); + }); + + test('fails when protocol version is missing', async () => { + const app = createWorkerApp(); + + // Upload bundle first + const uploadForm = formAutoContent({ + gemVersion, + password: 'my_password', + targetBundles: [String(BUNDLE_TIMESTAMP)], + [`bundle_${BUNDLE_TIMESTAMP}`]: createReadStream(getFixtureBundle()), + }); + + const uploadRes = await app + .inject() + .post('/upload-assets') + .payload(uploadForm.payload) + .headers(uploadForm.headers) + .end(); + expect(uploadRes.statusCode).toBe(412); + + // Try incremental render without protocol version + const payload = { + gemVersion, + password: 'my_password', + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + 412, + ); + + expect(res.payload).toContain('Unsupported renderer protocol version MISSING'); + }); + + test('succeeds when gem version is missing', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + const payload = { + protocolVersion, + password: 'my_password', + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }; + + const res = await callIncrementalRender( + app, + BUNDLE_TIMESTAMP, + 'd41d8cd98f00b204e9800998ecf8427e', + payload, + ); + + expect(res.headers['cache-control']).toBe('public, max-age=31536000'); + expect(res.payload).toBe('{"html":"Dummy Object"}'); + }); + + // TODO: Implement incremental updates and update this test + test('handles multiple NDJSON chunks but only processes first one for now', async () => { + const app = createWorkerApp(); + await uploadBundle(app); + + // Send multiple NDJSON chunks (only first one should be processed for now) + const firstChunk = createNDJSONPayload({ + gemVersion, + protocolVersion, + password: 'my_password', + renderingRequest: 'ReactOnRails.dummy', + dependencyBundleTimestamps: [String(BUNDLE_TIMESTAMP)], + }); + + const secondChunk = createNDJSONPayload({ + update: 'data', + timestamp: Date.now(), + }); + + const thirdChunk = createNDJSONPayload({ + anotherUpdate: 'more data', + sequence: 2, + }); + + const multiChunkPayload = firstChunk + secondChunk + thirdChunk; + + const res = await app + .inject() + .post(`/bundles/${BUNDLE_TIMESTAMP}/incremental-render/d41d8cd98f00b204e9800998ecf8427e`) + .payload(multiChunkPayload) + .headers({ + 'Content-Type': 'application/x-ndjson', + }) + .end(); + + // Should succeed and only process the first chunk + expect(res.statusCode).toBe(200); + expect(res.headers['cache-control']).toBe('public, max-age=31536000'); + expect(res.payload).toBe('{"html":"Dummy Object"}'); + }); + }); }); diff --git a/packages/react-on-rails-pro/src/AsyncPropsManager.ts b/packages/react-on-rails-pro/src/AsyncPropsManager.ts new file mode 100644 index 0000000000..1ad8156625 --- /dev/null +++ b/packages/react-on-rails-pro/src/AsyncPropsManager.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Shakacode LLC + * + * This file is NOT licensed under the MIT (open source) license. + * It is part of the React on Rails Pro offering and is licensed separately. + * + * Unauthorized copying, modification, distribution, or use of this file, + * via any medium, is strictly prohibited without a valid license agreement + * from Shakacode LLC. + * + * For licensing terms, please see: + * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md + */ + +type PromiseController = { + promise: Promise; + resolve: (propValue: unknown) => void; + reject: (reason: unknown) => void; + resolved: boolean; +}; + +class AsyncPropsManager { + private isClosed: boolean = false; + + private propNameToPromiseController = new Map(); + + // The function is not converted to an async function to ensure that: + // The function returns the same promise on successful scenario, so it can be used inside async react component + // Or with the `use` hook without causing an infinite loop or flicks during rendering + getProp(propName: string) { + const promiseController = this.getOrCreatePromiseController(propName); + if (!promiseController) { + return Promise.reject(AsyncPropsManager.getNoPropFoundError(propName)); + } + + return promiseController.promise; + } + + setProp(propName: string, propValue: unknown) { + const promiseController = this.getOrCreatePromiseController(propName); + if (!promiseController) { + throw new Error(`Can't set the async prop "${propName}" because the stream is already closed`); + } + + promiseController.resolve(propValue); + } + + endStream() { + if (this.isClosed) { + return; + } + + this.isClosed = true; + this.propNameToPromiseController.forEach((promiseController, propName) => { + if (!promiseController.resolved) { + promiseController.reject(AsyncPropsManager.getNoPropFoundError(propName)); + } + }); + } + + private getOrCreatePromiseController(propName: string) { + const promiseController = this.propNameToPromiseController.get(propName); + if (promiseController) { + return promiseController; + } + + if (this.isClosed) { + return undefined; + } + + const partialPromiseController = { + resolved: false, + }; + + let resolvePromise: PromiseController['resolve'] = () => {}; + let rejectPromise: PromiseController['reject'] = () => {}; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + const newPromiseController = Object.assign(partialPromiseController, { + promise, + resolve: resolvePromise, + reject: rejectPromise, + }); + this.propNameToPromiseController.set(propName, newPromiseController); + return newPromiseController; + } + + private static getNoPropFoundError(propName: string) { + return new Error( + `The async prop "${propName}" is not received. Esnure to send the async prop from ruby side`, + ); + } +} + +export default AsyncPropsManager; diff --git a/packages/react-on-rails-pro/src/RSCRequestTracker.ts b/packages/react-on-rails-pro/src/RSCRequestTracker.ts index 36116767ea..5d90d594fe 100644 --- a/packages/react-on-rails-pro/src/RSCRequestTracker.ts +++ b/packages/react-on-rails-pro/src/RSCRequestTracker.ts @@ -17,26 +17,10 @@ import { RSCPayloadStreamInfo, RSCPayloadCallback, RailsContextWithServerComponentMetadata, + GenerateRSCPayloadFunction, } from 'react-on-rails/types'; import { extractErrorMessage } from './utils.ts'; -/** - * Global function provided by React on Rails Pro for generating RSC payloads. - * - * This function is injected into the global scope during server-side rendering - * by the RORP rendering request. It handles the actual generation of React Server - * Component payloads on the server side. - * - * @see https://github.com/shakacode/react_on_rails_pro/blob/master/lib/react_on_rails_pro/server_rendering_js_code.rb - */ -declare global { - function generateRSCPayload( - componentName: string, - props: unknown, - railsContext: RailsContextWithServerComponentMetadata, - ): Promise; -} - /** * RSC Request Tracker - manages RSC payload generation and tracking for a single request. * @@ -52,8 +36,14 @@ class RSCRequestTracker { private railsContext: RailsContextWithServerComponentMetadata; - constructor(railsContext: RailsContextWithServerComponentMetadata) { + private generateRSCPayload?: GenerateRSCPayloadFunction; + + constructor( + railsContext: RailsContextWithServerComponentMetadata, + generateRSCPayload?: GenerateRSCPayloadFunction, + ) { this.railsContext = railsContext; + this.generateRSCPayload = generateRSCPayload; } /** @@ -120,17 +110,17 @@ class RSCRequestTracker { * @throws Error if generateRSCPayload is not available or fails */ async getRSCPayloadStream(componentName: string, props: unknown): Promise { - // Validate that the global generateRSCPayload function is available - if (typeof generateRSCPayload !== 'function') { + // Validate that the generateRSCPayload function is available + if (!this.generateRSCPayload) { throw new Error( - 'generateRSCPayload is not defined. Please ensure that you are using at least version 4.0.0 of ' + - 'React on Rails Pro and the Node renderer, and that ReactOnRailsPro.configuration.enable_rsc_support ' + - 'is set to true.', + 'generateRSCPayload function is not available. This could mean: ' + + '(1) ReactOnRailsPro.configuration.enable_rsc_support is not enabled, or ' + + '(2) You are using an incompatible version of React on Rails Pro (requires 4.0.0+).', ); } try { - const stream = await generateRSCPayload(componentName, props, this.railsContext); + const stream = await this.generateRSCPayload(componentName, props, this.railsContext); // Tee stream to allow for multiple consumers: // 1. stream1 - Used by React's runtime to perform server-side rendering diff --git a/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts b/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts index a233c156e9..cc78efd588 100644 --- a/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts +++ b/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts @@ -25,6 +25,7 @@ import { import { convertToError } from 'react-on-rails/serverRenderUtils'; import handleError from './handleErrorRSC.ts'; import ReactOnRails from './ReactOnRails.full.ts'; +import AsyncPropsManager from './AsyncPropsManager.ts'; import { streamServerRenderedComponent, @@ -104,6 +105,26 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; +function addAsyncPropsCapabilityToComponentProps< + AsyncPropsType extends Record, + PropsType extends Record, +>(props: PropsType) { + const asyncPropManager = new AsyncPropsManager(); + const propsAfterAddingAsyncProps = { + ...props, + getReactOnRailsAsyncProp: (propName: PropName) => { + return asyncPropManager.getProp(propName as string) as Promise; + }, + }; + + return { + asyncPropManager, + props: propsAfterAddingAsyncProps, + }; +} + +ReactOnRails.addAsyncPropsCapabilityToComponentProps = addAsyncPropsCapabilityToComponentProps; + ReactOnRails.isRSCBundle = true; export * from 'react-on-rails/types'; diff --git a/packages/react-on-rails-pro/src/createReactOnRailsPro.ts b/packages/react-on-rails-pro/src/createReactOnRailsPro.ts index 28fe296411..bf2aed727e 100644 --- a/packages/react-on-rails-pro/src/createReactOnRailsPro.ts +++ b/packages/react-on-rails-pro/src/createReactOnRailsPro.ts @@ -45,6 +45,7 @@ type ReactOnRailsProSpecificFunctions = Pick< | 'reactOnRailsStoreLoaded' | 'streamServerRenderedReactComponent' | 'serverRenderRSCReactComponent' + | 'addAsyncPropsCapabilityToComponentProps' >; // Pro client startup with immediate hydration support @@ -133,6 +134,10 @@ export default function createReactOnRailsPro( serverRenderRSCReactComponent(): any { throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only'); }, + + addAsyncPropsCapabilityToComponentProps() { + throw new Error('addAsyncPropsCapabilityToComponentProps is supported in RSC bundle only'); + }, }; // Type assertion is safe here because: @@ -153,6 +158,11 @@ export default function createReactOnRailsPro( reactOnRailsPro.serverRenderRSCReactComponent; } + if (reactOnRailsPro.addAsyncPropsCapabilityToComponentProps) { + reactOnRailsProSpecificFunctions.addAsyncPropsCapabilityToComponentProps = + reactOnRailsPro.addAsyncPropsCapabilityToComponentProps; + } + // Assign Pro-specific functions to the ReactOnRailsPro object using Object.assign // This pattern ensures we add exactly what's defined in the type, nothing more, nothing less Object.assign(reactOnRailsPro, reactOnRailsProSpecificFunctions); diff --git a/packages/react-on-rails-pro/src/streamingUtils.ts b/packages/react-on-rails-pro/src/streamingUtils.ts index 1502232dd6..f99346efca 100644 --- a/packages/react-on-rails-pro/src/streamingUtils.ts +++ b/packages/react-on-rails-pro/src/streamingUtils.ts @@ -187,11 +187,19 @@ export const streamServerRenderedComponent = ( renderStrategy: StreamRenderer, handleError: (options: ErrorOptions) => PipeableOrReadableStream, ): T => { - const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options; + const { + name: componentName, + domNodeId, + trace, + props, + railsContext, + throwJsErrors, + generateRSCPayload, + } = options; assertRailsContextWithServerComponentMetadata(railsContext); const postSSRHookTracker = new PostSSRHookTracker(); - const rscRequestTracker = new RSCRequestTracker(railsContext); + const rscRequestTracker = new RSCRequestTracker(railsContext, generateRSCPayload); const streamingTrackers = { postSSRHookTracker, rscRequestTracker, diff --git a/packages/react-on-rails-pro/tests/AsyncPropManager.test.ts b/packages/react-on-rails-pro/tests/AsyncPropManager.test.ts new file mode 100644 index 0000000000..c981bc626f --- /dev/null +++ b/packages/react-on-rails-pro/tests/AsyncPropManager.test.ts @@ -0,0 +1,142 @@ +import AsyncPropsManager from '../src/AsyncPropsManager.ts'; + +describe('Access AsyncPropManager prop before setting it', () => { + let manager: AsyncPropsManager; + let getPropPromise: Promise; + + beforeEach(() => { + manager = new AsyncPropsManager(); + getPropPromise = manager.getProp('randomProp'); + manager.setProp('randomProp', 'Fake Value'); + }); + + it('returns the same value', async () => { + await expect(getPropPromise).resolves.toBe('Fake Value'); + }); + + it('returns the same promise on success scenarios', async () => { + const secondGetPropPromise = manager.getProp('randomProp'); + expect(secondGetPropPromise).toBe(getPropPromise); + await expect(getPropPromise).resolves.toBe('Fake Value'); + }); + + it('allows accessing multiple props', async () => { + const getSecondPropPromise = manager.getProp('secondRandomProp'); + await expect(getPropPromise).resolves.toBe('Fake Value'); + manager.setProp('secondRandomProp', 'Another Fake Value'); + await expect(getSecondPropPromise).resolves.toBe('Another Fake Value'); + }); +}); + +describe('Access AsyncPropManager prop after setting it', () => { + let manager: AsyncPropsManager; + let getPropPromise: Promise; + + beforeEach(() => { + manager = new AsyncPropsManager(); + manager.setProp('randomProp', 'Value got after setting'); + getPropPromise = manager.getProp('randomProp'); + }); + + it('can set the prop before getting it', async () => { + await expect(getPropPromise).resolves.toBe('Value got after setting'); + }); + + it('returns the same promise on success scenarios', async () => { + const secondGetPropPromise = manager.getProp('randomProp'); + expect(secondGetPropPromise).toBe(getPropPromise); + await expect(getPropPromise).resolves.toBe('Value got after setting'); + }); + + it('allows accessing multiple props', async () => { + manager.setProp('secondRandomProp', 'Another Fake Value'); + const getSecondPropPromise = manager.getProp('secondRandomProp'); + await expect(getPropPromise).resolves.toBe('Value got after setting'); + await expect(getSecondPropPromise).resolves.toBe('Another Fake Value'); + }); +}); + +describe('Access AsyncPropManager prop after closing the stream', () => { + let manager: AsyncPropsManager; + let getPropPromise: Promise; + + beforeEach(() => { + manager = new AsyncPropsManager(); + manager.setProp('prop accessed after closing', 'Value got after closing the stream'); + manager.endStream(); + getPropPromise = manager.getProp('prop accessed after closing'); + }); + + it('can set the prop before getting it', async () => { + await expect(getPropPromise).resolves.toBe('Value got after closing the stream'); + }); + + it('returns the same promise on success scenarios', async () => { + const secondGetPropPromise = manager.getProp('prop accessed after closing'); + expect(secondGetPropPromise).toBe(getPropPromise); + await expect(getPropPromise).resolves.toBe('Value got after closing the stream'); + }); +}); + +describe('Access non sent AsyncPropManager prop', () => { + it('throws an error if non-existing prop is sent after closing the stream', async () => { + const manager = new AsyncPropsManager(); + manager.endStream(); + await expect(manager.getProp('Non Existing Prop')).rejects.toThrow( + /The async prop "Non Existing Prop" is not received/, + ); + }); + + it('rejects getPropPromise if the stream is closed before getting the prop value', async () => { + const manager = new AsyncPropsManager(); + const getPropPromise = manager.getProp('wrongProp'); + manager.endStream(); + await expect(getPropPromise).rejects.toThrow(/The async prop "wrongProp" is not received/); + }); + + it('throws an error if a prop is set after closing the stream', () => { + const manager = new AsyncPropsManager(); + manager.endStream(); + expect(() => manager.setProp('wrongProp', 'Nothing')).toThrow( + /Can't set the async prop "wrongProp" because the stream is already closed/, + ); + }); +}); + +describe('Accessing AsyncPropManager prop in complex scenarios', () => { + it('accepts multiple received props and reject multiple non sent props', async () => { + const manager = new AsyncPropsManager(); + const accessBeforeSetPromise = manager.getProp('accessBeforeSetProp'); + const secondAccessBeforeSetPromise = manager.getProp('secondAccessBeforeSetProp'); + const nonExistingPropPromise = manager.getProp('nonExistingProp'); + + // Setting and getting props + manager.setProp('setBeforeAccessProp', 'Set Before Access Prop Value'); + manager.setProp('accessBeforeSetProp', 'Access Before Set Prop Value'); + await expect(accessBeforeSetPromise).resolves.toBe('Access Before Set Prop Value'); + await expect(manager.getProp('setBeforeAccessProp')).resolves.toBe('Set Before Access Prop Value'); + + // Setting another prop + manager.setProp('secondAccessBeforeSetProp', 'Second Access Before Set Prop Value'); + await expect(secondAccessBeforeSetPromise).resolves.toBe('Second Access Before Set Prop Value'); + + // Ensure all props return the same promise + expect(manager.getProp('accessBeforeSetProp')).toBe(manager.getProp('accessBeforeSetProp')); + expect(manager.getProp('secondAccessBeforeSetProp')).toBe(manager.getProp('secondAccessBeforeSetProp')); + expect(manager.getProp('setBeforeAccessProp')).toBe(manager.getProp('setBeforeAccessProp')); + + // Access props one more time + await expect(manager.getProp('setBeforeAccessProp')).resolves.toBe('Set Before Access Prop Value'); + await expect(manager.getProp('accessBeforeSetProp')).resolves.toBe('Access Before Set Prop Value'); + + // Non existing props + manager.endStream(); + await expect(nonExistingPropPromise).rejects.toThrow(/The async prop "nonExistingProp" is not received/); + await expect(manager.getProp('wrongProp')).rejects.toThrow(/The async prop "wrongProp" is not received/); + + // Setting after closing + expect(() => manager.setProp('wrongProp', 'Nothing')).toThrow( + /Can't set the async prop "wrongProp" because the stream is already closed/, + ); + }); +}); diff --git a/packages/react-on-rails-pro/tests/testUtils.ts b/packages/react-on-rails-pro/tests/testUtils.ts index 941f6fb265..835e257ea8 100644 --- a/packages/react-on-rails-pro/tests/testUtils.ts +++ b/packages/react-on-rails-pro/tests/testUtils.ts @@ -9,8 +9,8 @@ import { Readable } from 'stream'; * }} Object containing the stream and push function */ export const createNodeReadableStream = () => { - const pendingChunks: Buffer[] = []; - let pushFn: ((chunk: Buffer | undefined) => void) | null = null; + const pendingChunks: unknown[] = []; + let pushFn: (chunk: unknown) => void; const stream = new Readable({ read() { pushFn = this.push.bind(this); @@ -20,7 +20,7 @@ export const createNodeReadableStream = () => { }, }); - const push = (chunk: Buffer) => { + const push = (chunk: unknown) => { if (pushFn) { pushFn(chunk); } else { diff --git a/packages/react-on-rails/src/base/client.ts b/packages/react-on-rails/src/base/client.ts index a4667be7d2..341b379873 100644 --- a/packages/react-on-rails/src/base/client.ts +++ b/packages/react-on-rails/src/base/client.ts @@ -51,6 +51,7 @@ export type BaseClientObjectType = Omit< | 'reactOnRailsStoreLoaded' | 'streamServerRenderedReactComponent' | 'serverRenderRSCReactComponent' + | 'addAsyncPropsCapabilityToComponentProps' >; // Cache to track created objects and their registries diff --git a/packages/react-on-rails/src/createReactOnRails.ts b/packages/react-on-rails/src/createReactOnRails.ts index e422a0c0af..8bc16d757d 100644 --- a/packages/react-on-rails/src/createReactOnRails.ts +++ b/packages/react-on-rails/src/createReactOnRails.ts @@ -22,6 +22,7 @@ type ReactOnRailsCoreSpecificFunctions = Pick< | 'reactOnRailsStoreLoaded' | 'streamServerRenderedReactComponent' | 'serverRenderRSCReactComponent' + | 'addAsyncPropsCapabilityToComponentProps' >; export default function createReactOnRails( @@ -76,6 +77,10 @@ export default function createReactOnRails( serverRenderRSCReactComponent(): any { throw new Error('serverRenderRSCReactComponent requires react-on-rails-pro package'); }, + + addAsyncPropsCapabilityToComponentProps() { + throw new Error('addAsyncPropsCapabilityToComponentProps requires react-on-rails-pro package'); + }, }; // Type assertion is safe here because: diff --git a/packages/react-on-rails/src/types/index.ts b/packages/react-on-rails/src/types/index.ts index ff3ef4869a..78fe0be822 100644 --- a/packages/react-on-rails/src/types/index.ts +++ b/packages/react-on-rails/src/types/index.ts @@ -139,6 +139,12 @@ type RenderFunctionResult = RenderFunctionSyncResult | RenderFunctionAsyncResult type StreamableComponentResult = ReactElement | Promise; +type AsyncPropsManager = { + getProp: (propName: string) => Promise; + setProp: (propName: string, propValue: unknown) => void; + endStream: () => void; +}; + /** * Render-functions are used to create dynamic React components or server-rendered HTML with side effects. * They receive two arguments: props and railsContext. @@ -210,11 +216,18 @@ export interface RegisteredComponent { export type ItemRegistrationCallback = (component: T) => void; +export type GenerateRSCPayloadFunction = ( + componentName: string, + props: unknown, + railsContext: RailsContextWithServerComponentMetadata, +) => Promise; + interface Params { props?: Record; railsContext?: RailsContext; domNodeId?: string; trace?: boolean; + generateRSCPayload?: GenerateRSCPayloadFunction; } export interface RenderParams extends Params { @@ -355,6 +368,15 @@ export type RSCPayloadStreamInfo = { export type RSCPayloadCallback = (streamInfo: RSCPayloadStreamInfo) => void; +export type WithAsyncProps< + AsyncPropsType extends Record, + PropsType extends Record, +> = PropsType & { + getReactOnRailsAsyncProp: ( + propName: PropName, + ) => Promise; +}; + /** Contains the parts of the `ReactOnRails` API intended for internal use only. */ export interface ReactOnRailsInternal extends ReactOnRails { /** @@ -469,6 +491,19 @@ export interface ReactOnRailsInternal extends ReactOnRails { * Indicates if the RSC bundle is being used. */ isRSCBundle: boolean; + /** + * Adds the getAsyncProp function to the component props object + * @returns An object containitng: the AsyncPropsManager and the component props after adding the getAsyncProp to it + */ + addAsyncPropsCapabilityToComponentProps: < + AsyncPropsType extends Record, + PropsType extends Record, + >( + props: PropsType, + ) => { + asyncPropManager: AsyncPropsManager; + props: WithAsyncProps; + }; } export type RenderStateHtml = FinalHtmlResult | Promise; diff --git a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb index 69c20af010..2e20ebf159 100644 --- a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb +++ b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb @@ -137,6 +137,11 @@ def stream_react_component(component_name, options = {}) end end + def stream_react_component_with_async_props(component_name, options = {}, &props_block) + options[:async_props_block] = props_block + stream_react_component(component_name, options) + end + # Renders the React Server Component (RSC) payload for a given component. This helper generates # a special format designed by React for serializing server components and transmitting them # to the client. diff --git a/react_on_rails_pro/lib/react_on_rails_pro.rb b/react_on_rails_pro/lib/react_on_rails_pro.rb index 5dbe2dafae..244d90c6cc 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro.rb @@ -2,6 +2,9 @@ require "rails" +# Apply HTTPX bug fix for stream_bidi plugin +require "react_on_rails_pro/httpx_stream_bidi_patch" + require "react_on_rails_pro/request" require "react_on_rails_pro/version" require "react_on_rails_pro/constants" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb new file mode 100644 index 0000000000..078bb9a085 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # Emitter class for sending async props incrementally during streaming render + # Used by stream_react_component_with_async_props helper + class AsyncPropsEmitter + def initialize(bundle_timestamp, request_stream) + @bundle_timestamp = bundle_timestamp + @request_stream = request_stream + end + + # Public API: emit.call('propName', propValue) + # Sends an update chunk to the node renderer to resolve an async prop + def call(prop_name, prop_value) + update_chunk = generate_update_chunk(prop_name, prop_value) + @request_stream << "#{update_chunk.to_json}\n" + rescue StandardError => e + Rails.logger.error do + "[ReactOnRailsPro] Failed to send async prop '#{prop_name}': #{e.message}" + end + # Continue - don't abort entire render because one prop failed + end + + # Generates the chunk that should be executed when the request stream closes + # This tells the asyncPropsManager to end the stream + def end_stream_chunk + { + bundleTimestamp: @bundle_timestamp, + updateChunk: generate_end_stream_js + } + end + + private + + def generate_update_chunk(prop_name, value) + { + bundleTimestamp: @bundle_timestamp, + updateChunk: generate_set_prop_js(prop_name, value) + } + end + + def generate_set_prop_js(prop_name, value) + <<~JS.strip + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.setProp(#{prop_name.to_json}, #{value.to_json}); + })() + JS + end + + def generate_end_stream_js + <<~JS.strip + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.endStream(); + })() + JS + end + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb b/react_on_rails_pro/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb new file mode 100644 index 0000000000..81d0c1aeaf --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Temporary monkey-patch for HTTPX bug with stream_bidi plugin + persistent connections +# +# Issue: When using HTTPX with both `persistent: true` and `.plugin(:stream_bidi)`, +# calling `session.close` raises NoMethodError: undefined method `inflight?` for +# an instance of HTTPX::Plugins::StreamBidi::Signal +# +# Root cause: The StreamBidi::Signal class is registered as a selectable in the +# selector but doesn't implement the `inflight?` method required by Selector#terminate +# (called during session close at lib/httpx/selector.rb:64) +# +# This patch adds the missing `inflight?` method to Signal. The method returns false +# because Signal objects are just pipe-based notification mechanisms to wake up the +# selector loop - they never have "inflight" HTTP requests or pending data buffers. +# +# The `unless method_defined?` guard ensures this patch won't override the method +# when the official fix is released, making it safe to keep in the codebase. +# +# Can be removed once httpx releases an official fix. +# Affected versions: httpx 1.5.1 (and possibly earlier) +# See: https://github.com/HoneyryderChuck/httpx/issues/XXX + +module HTTPX + module Plugins + module StreamBidi + class Signal + unless method_defined?(:inflight?) + def inflight? + false + end + end + end + end + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/request.rb b/react_on_rails_pro/lib/react_on_rails_pro/request.rb index e4b6a70d56..b11be71f3e 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/request.rb @@ -3,13 +3,15 @@ require "uri" require "httpx" require_relative "stream_request" +require_relative "async_props_emitter" module ReactOnRailsPro class Request # rubocop:disable Metrics/ClassLength class << self def reset_connection - @connection&.close - @connection = create_connection + @standard_connection&.close + @standard_connection = nil + reset_thread_local_incremental_connections end def render_code(path, js_code, send_bundle) @@ -27,12 +29,56 @@ def render_code_as_stream(path, js_code, is_rsc_payload:) "rendering any RSC payload." end - ReactOnRailsPro::StreamRequest.create do |send_bundle| - form = form_with_code(js_code, send_bundle) + ReactOnRailsPro::StreamRequest.create do |send_bundle, _barrier| + if send_bundle + Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" } + upload_assets + end + + form = form_with_code(js_code, false) perform_request(path, form: form, stream: true) end end + def render_code_with_incremental_updates(path, js_code, async_props_block:, is_rsc_payload:) + Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" } + + # Determine bundle timestamp based on RSC support + pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool + bundle_timestamp = is_rsc_payload ? pool.rsc_bundle_hash : pool.server_bundle_hash + + ReactOnRailsPro::StreamRequest.create do |send_bundle, barrier| + if send_bundle + Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" } + upload_assets + end + + # Build bidirectional streaming request + request = incremental_connection.build_request( + "POST", + path, + headers: { "content-type" => "application/x-ndjson" }, + body: [] + ) + + # Create emitter and use it to generate initial request data + emitter = ReactOnRailsPro::AsyncPropsEmitter.new(bundle_timestamp, request) + initial_data = build_initial_incremental_request(js_code, emitter) + + response = incremental_connection.request(request, stream: true) + request << "#{initial_data.to_json}\n" + + # Execute async props block in background using barrier + barrier.async do + async_props_block.call(emitter) + ensure + request.close + end + + response + end + end + def upload_assets Rails.logger.info { "[ReactOnRailsPro] Uploading assets" } @@ -82,8 +128,25 @@ def asset_exists_on_vm_renderer?(filename) private + # rubocop:disable Naming/MemoizedInstanceVariableName def connection - @connection ||= create_connection + @standard_connection ||= create_standard_connection + end + # rubocop:enable Naming/MemoizedInstanceVariableName + + # Thread-local connection for incremental rendering + # Each thread gets its own persistent connection to avoid connection pool issues + def incremental_connection + Thread.current[:react_on_rails_incremental_connection] ||= create_incremental_connection + end + + def reset_thread_local_incremental_connections + # Close all thread-local incremental connections + Thread.list.each do |thread| + conn = thread[:react_on_rails_incremental_connection] + conn&.close + thread[:react_on_rails_incremental_connection] = nil + end end def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity @@ -217,7 +280,22 @@ def common_form_data ReactOnRailsPro::Utils.common_form_data end - def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def build_initial_incremental_request(js_code, emitter) + common_form_data.merge( + renderingRequest: js_code, + onRequestClosedUpdateChunk: emitter.end_stream_chunk + ) + end + + def create_standard_connection + build_connection_config.plugin(:stream) + end + + def create_incremental_connection + build_connection_config.plugin(:stream_bidi) + end + + def build_connection_config # rubocop:disable Metrics/MethodLength, Metrics/AbcSize url = ReactOnRailsPro.configuration.renderer_url Rails.logger.info do "[ReactOnRailsPro] Setting up Node Renderer connection to #{url}" @@ -261,7 +339,6 @@ def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize nil end ) - .plugin(:stream) # See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options .with( origin: url, diff --git a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb index 89ef9f136c..fb0919dd86 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb @@ -36,17 +36,31 @@ def generate_rsc_payload_js_function(render_options) renderingRequest, rscBundleHash: '#{ReactOnRailsPro::Utils.rsc_bundle_hash}', } - if (typeof generateRSCPayload !== 'function') { - globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) { - const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters; - const propsString = JSON.stringify(props); - const newRenderingRequest = renderingRequest.replace(/\\(\\s*\\)\\s*$/, `('${componentName}', ${propsString})`); - return runOnOtherBundle(rscBundleHash, newRenderingRequest); - } + const runOnOtherBundle = globalThis.runOnOtherBundle; + const generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) { + const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters; + const propsString = JSON.stringify(props); + const newRenderingRequest = renderingRequest.replace(/\\(\\s*\\)\\s*$/, `('${componentName}', ${propsString})`); + return runOnOtherBundle(rscBundleHash, newRenderingRequest); } JS end + # Generates JavaScript code for async props setup when incremental rendering is enabled + # @param render_options [Object] Options that control the rendering behavior + # @return [String] JavaScript code that sets up AsyncPropsManager or empty string + def async_props_setup_js(render_options) + return "" unless render_options.internal_option(:async_props_block) + + <<-JS + if (ReactOnRails.isRSCBundle) { + var { props: propsWithAsyncProps, asyncPropManager } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps); + usedProps = propsWithAsyncProps; + sharedExecutionContext.set("asyncPropsManager", asyncPropManager); + } + JS + end + # Main rendering function that generates JavaScript code for server-side rendering # @param props_string [String] JSON string of props to pass to the React component # @param rails_context [String] JSON string of Rails context data @@ -85,6 +99,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend #{ssr_pre_hook_js} #{redux_stores} var usedProps = typeof props === 'undefined' ? #{props_string} : props; + #{async_props_setup_js(render_options)} return ReactOnRails[#{render_function_name}]({ name: componentName, domNodeId: '#{render_options.dom_id}', @@ -93,6 +108,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend railsContext: railsContext, throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors}, renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises}, + generateRSCPayload: typeof generateRSCPayload !== 'undefined' ? generateRSCPayload : undefined, }); })() JS diff --git a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb index c2f7a99499..75c15e7d0c 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb @@ -53,12 +53,27 @@ def exec_server_render_js(js_code, render_options) end def eval_streaming_js(js_code, render_options) - path = prepare_render_path(js_code, render_options) - ReactOnRailsPro::Request.render_code_as_stream( - path, - js_code, - is_rsc_payload: ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming? - ) + is_rsc_payload = ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming? + async_props_block = render_options.internal_option(:async_props_block) + + if async_props_block + # Use incremental rendering when async props block is provided + path = prepare_incremental_render_path(js_code, render_options) + ReactOnRailsPro::Request.render_code_with_incremental_updates( + path, + js_code, + async_props_block: async_props_block, + is_rsc_payload: is_rsc_payload + ) + else + # Use standard streaming when no async props block + path = prepare_render_path(js_code, render_options) + ReactOnRailsPro::Request.render_code_as_stream( + path, + js_code, + is_rsc_payload: is_rsc_payload + ) + end end def eval_js(js_code, render_options, send_bundle: false) @@ -96,16 +111,27 @@ def rsc_bundle_hash end def prepare_render_path(js_code, render_options) + # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119 + # From the request path + # path = "/bundles/#{@bundle_hash}/render" + build_render_path(js_code, render_options, "render") + end + + def prepare_incremental_render_path(js_code, render_options) + build_render_path(js_code, render_options, "incremental-render") + end + + private + + def build_render_path(js_code, render_options, endpoint) ReactOnRailsPro::ServerRenderingPool::ProRendering .set_request_digest_on_render_options(js_code, render_options) rsc_support_enabled = ReactOnRailsPro.configuration.enable_rsc_support is_rendering_rsc_payload = rsc_support_enabled && render_options.rsc_payload_streaming? bundle_hash = is_rendering_rsc_payload ? rsc_bundle_hash : server_bundle_hash - # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119 - # From the request path - # path = "/bundles/#{@bundle_hash}/render" - "/bundles/#{bundle_hash}/render/#{render_options.request_digest}" + + "/bundles/#{bundle_hash}/#{endpoint}/#{render_options.request_digest}" end def fallback_exec_js(js_code, render_options, error) diff --git a/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb b/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb index 4090958a0f..6a72eaf235 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "async" +require "async/barrier" + module ReactOnRailsPro class StreamDecorator def initialize(component) @@ -92,22 +95,28 @@ def initialize(&request_block) def each_chunk(&block) return enum_for(:each_chunk) unless block - send_bundle = false - error_body = +"" - loop do - stream_response = @request_executor.call(send_bundle) - - # Chunks can be merged during streaming, so we separate them by newlines - # Also, we check the status code inside the loop block because calling `status` outside the loop block - # is blocking, it will wait for the response to be fully received - # Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details - process_response_chunks(stream_response, error_body, &block) - break - rescue HTTPX::HTTPError => e - send_bundle = handle_http_error(e, error_body, send_bundle) - rescue HTTPX::ReadTimeoutError => e - raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \ - "Original error:\n#{e}\n#{e.backtrace}" + Sync do + barrier = Async::Barrier.new + + send_bundle = false + error_body = +"" + loop do + stream_response = @request_executor.call(send_bundle, barrier) + + # Chunks can be merged during streaming, so we separate them by newlines + # Also, we check the status code inside the loop block because calling `status` outside the loop block + # is blocking, it will wait for the response to be fully received + # Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details + process_response_chunks(stream_response, error_body, &block) + break + rescue HTTPX::HTTPError => e + send_bundle = handle_http_error(e, error_body, send_bundle) + rescue HTTPX::ReadTimeoutError => e + raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \ + "Original error:\n#{e}\n#{e.backtrace}" + end + + barrier.wait end end diff --git a/react_on_rails_pro/spec/dummy/Procfile.dev b/react_on_rails_pro/spec/dummy/Procfile.dev index 4b5b336ea5..74c5432fd8 100644 --- a/react_on_rails_pro/spec/dummy/Procfile.dev +++ b/react_on_rails_pro/spec/dummy/Procfile.dev @@ -1,6 +1,6 @@ # Procfile for development with hot reloading of JavaScript and CSS -rails: rails s -p 3000 +# rails: rails s -p 3000 # Run the hot reload server for client development webpack-dev-server: HMR=true bin/shakapacker-dev-server diff --git a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb index 7a33950f91..70d94093c3 100644 --- a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb +++ b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb @@ -41,6 +41,10 @@ def stream_async_components_for_testing stream_view_containing_react_components(template: "/pages/stream_async_components_for_testing") end + def test_incremental_rendering + stream_view_containing_react_components(template: "/pages/test_incremental_rendering") + end + def cached_stream_async_components_for_testing stream_view_containing_react_components(template: "/pages/cached_stream_async_components_for_testing") end diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/test_incremental_rendering.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/test_incremental_rendering.html.erb new file mode 100644 index 0000000000..d0483c8e7b --- /dev/null +++ b/react_on_rails_pro/spec/dummy/app/views/pages/test_incremental_rendering.html.erb @@ -0,0 +1,11 @@ +

Incremental Rendering Test

+

Testing AsyncPropsComponent with incremental rendering

+ +<%= stream_react_component_with_async_props("AsyncPropsComponent", props: { name: "John Doe", age: 30, description: "Software Engineer" }) do |emit| + # Simulate fetching async props + sleep 1 + emit.call("books", ["The Pragmatic Programmer", "Clean Code", "Design Patterns"]) + + sleep 1 + emit.call("researches", ["Machine Learning Study", "React Performance Optimization", "Database Indexing Strategies"]) +end %> diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/AsyncPropsComponent.tsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/AsyncPropsComponent.tsx new file mode 100644 index 0000000000..f8f20d0363 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/AsyncPropsComponent.tsx @@ -0,0 +1,56 @@ +/// + +import * as React from 'react'; +import { Suspense } from 'react'; +import { WithAsyncProps } from 'react-on-rails-pro'; + +type SyncPropsType = { + name: string; + age: number; + description: string; +}; + +type AsyncPropsType = { + books: string[]; + researches: string[]; +}; + +type PropsType = WithAsyncProps; + +const AsyncArrayComponent = async ({ items }: { items: Promise }) => { + const resolvedItems = await items; + + return ( +
    + {resolvedItems.map((value) => ( +
  1. {value}
  2. + ))} +
+ ); +}; + +const AsyncPropsComponent = ({ name, age, description, getReactOnRailsAsyncProp }: PropsType) => { + const booksPromise = getReactOnRailsAsyncProp('books'); + const researchesPromise = getReactOnRailsAsyncProp('researches'); + + return ( +
+

Async Props Component

+

Name: {name}

+

Age: {age}

+

Description: {description}

+ +

Books

+ Loading Books...

}> + +
+ +

Researches

+ Loading Researches...

}> + +
+
+ ); +}; + +export default AsyncPropsComponent; diff --git a/react_on_rails_pro/spec/dummy/config/routes.rb b/react_on_rails_pro/spec/dummy/config/routes.rb index 1d2f2b4e0d..dba3ad4c12 100644 --- a/react_on_rails_pro/spec/dummy/config/routes.rb +++ b/react_on_rails_pro/spec/dummy/config/routes.rb @@ -27,6 +27,7 @@ as: :stream_async_components_for_testing get "cached_stream_async_components_for_testing" => "pages#cached_stream_async_components_for_testing", as: :cached_stream_async_components_for_testing + get "test_incremental_rendering" => "pages#test_incremental_rendering", as: :test_incremental_rendering get "stream_async_components_for_testing_client_render" => "pages#stream_async_components_for_testing_client_render", as: :stream_async_components_for_testing_client_render get "rsc_posts_page_over_http" => "pages#rsc_posts_page_over_http", as: :rsc_posts_page_over_http diff --git a/react_on_rails_pro/spec/dummy/spec/requests/incremental_rendering_integration_spec.rb b/react_on_rails_pro/spec/dummy/spec/requests/incremental_rendering_integration_spec.rb new file mode 100644 index 0000000000..f71152bfb7 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/spec/requests/incremental_rendering_integration_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Integration tests for incremental rendering with bidirectional streaming +# +# IMPORTANT: These tests require a running node-renderer server. +# Before running these tests: +# 1. cd packages/node-renderer +# 2. yarn test:setup # or equivalent command to start the test server +# 3. Keep the server running in a separate terminal +# +# Then run these tests: +# bundle exec rspec spec/requests/incremental_rendering_integration_spec.rb +# +describe "Incremental Rendering Integration", :integration do + let(:server_bundle_hash) { "test_incremental_bundle" } + # Fixture bundle paths (real files on disk) + let(:fixture_bundle_path) do + File.expand_path( + "../../../../packages/node-renderer/tests/fixtures/bundle-incremental.js", + __dir__ + ) + end + let(:fixture_rsc_bundle_path) do + File.expand_path( + "../../../../packages/node-renderer/tests/fixtures/secondary-bundle-incremental.js", + __dir__ + ) + end + let(:rsc_bundle_hash) { "test_incremental_rsc_bundle" } + + before do + allow(ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool).to receive_messages( + server_bundle_hash: server_bundle_hash, + rsc_bundle_hash: rsc_bundle_hash, + renderer_bundle_file_name: "#{server_bundle_hash}.js", + rsc_renderer_bundle_file_name: "#{rsc_bundle_hash}.js" + ) + + # Enable RSC support for these tests + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(true) + + # Mock populate_form_with_bundle_and_assets to use fixture bundles directly + # rubocop:disable Lint/UnusedBlockArgument + allow(ReactOnRailsPro::Request).to receive(:populate_form_with_bundle_and_assets) do |form, check_bundle:| + # rubocop:enable Lint/UnusedBlockArgument + form["bundle_#{server_bundle_hash}"] = { + body: Pathname.new(fixture_bundle_path), + content_type: "text/javascript", + filename: "#{server_bundle_hash}.js" + } + + form["bundle_#{rsc_bundle_hash}"] = { + body: Pathname.new(fixture_rsc_bundle_path), + content_type: "text/javascript", + filename: "#{rsc_bundle_hash}.js" + } + end + + # Mock AsyncPropsEmitter chunk generation methods to work with fixture bundles + # Only mock the chunk generation, not the actual call/streaming logic + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(ReactOnRailsPro::AsyncPropsEmitter) + .to receive(:generate_update_chunk) do |emitter, _prop_name, value| + bundle_timestamp = emitter.instance_variable_get(:@bundle_timestamp) + { + bundleTimestamp: bundle_timestamp, + # Add newline to the value so the fixture bundle writes it with newline + updateChunk: "ReactOnRails.addStreamValue(#{value.to_json} + '\\n')" + } + end + + allow_any_instance_of(ReactOnRailsPro::AsyncPropsEmitter).to receive(:end_stream_chunk).and_call_original + allow_any_instance_of(ReactOnRailsPro::AsyncPropsEmitter).to receive(:generate_end_stream_js).and_return( + "ReactOnRails.endStream()" + ) + # rubocop:enable RSpec/AnyInstance + + # Reset any existing connections to ensure clean state + ReactOnRailsPro::Request.reset_connection + end + + after do + ReactOnRailsPro::Request.reset_connection + end + + describe "upload_assets" do + it "successfully uploads fixture bundles to the node renderer" do + expect do + ReactOnRailsPro::Request.upload_assets + end.not_to raise_error + end + end + + describe "render_code" do + it "renders simple non-streaming request using ReactOnRails.dummy" do + # Upload bundles first + ReactOnRailsPro::Request.upload_assets + + # Construct the render path: /bundles/:bundleTimestamp/render/:renderRequestDigest + js_code = "ReactOnRails.dummy" + request_digest = Digest::MD5.hexdigest(js_code) + render_path = "/bundles/#{server_bundle_hash}/render/#{request_digest}" + + # Render using the fixture bundle + response = ReactOnRailsPro::Request.render_code(render_path, js_code, false) + + expect(response.status).to eq(200) + expect(response.body.to_s).to include("Dummy Object") + end + end + + describe "render_code_with_incremental_updates" do + it "sends stream values and receives them in the response" do + # Upload bundles first + ReactOnRailsPro::Request.upload_assets + + # Construct the incremental render path + js_code = "ReactOnRails.getStreamValues()" + request_digest = Digest::MD5.hexdigest(js_code) + render_path = "/bundles/#{server_bundle_hash}/incremental-render/#{request_digest}" + + # Perform incremental rendering with stream updates + stream = ReactOnRailsPro::Request.render_code_with_incremental_updates( + render_path, + js_code, + async_props_block: proc { |emitter| + emitter.call("prop1", "value1") + emitter.call("prop2", "value2") + emitter.call("prop3", "value3") + }, + is_rsc_payload: false + ) + + # Collect all chunks from the stream + chunks = [] + stream.each_chunk do |chunk| + chunks << chunk + end + + # Verify we received all the values + response_text = chunks.join + expect(response_text).to include("value1") + expect(response_text).to include("value2") + expect(response_text).to include("value3") + end + + it "streams bidirectionally - each_chunk receives chunks while async_props_block is still running" do + # Upload bundles first + ReactOnRailsPro::Request.upload_assets + + # Construct the incremental render path + js_code = "ReactOnRails.getStreamValues()" + request_digest = Digest::MD5.hexdigest(js_code) + render_path = "/bundles/#{server_bundle_hash}/incremental-render/#{request_digest}" + + # Single condition to signal when each chunk is received + chunk_received = Async::Condition.new + + # Wrap the test in a timeout to prevent hanging forever on deadlock + Timeout.timeout(10) do + # Perform incremental rendering with bidirectional verification + stream = ReactOnRailsPro::Request.render_code_with_incremental_updates( + render_path, + js_code, + async_props_block: proc { |emitter| + # Send first value and wait for confirmation + emitter.call("prop1", "value1") + chunk_received.wait + + # Send second value and wait for confirmation + emitter.call("prop2", "value2") + chunk_received.wait + + # Send third value and wait for confirmation + emitter.call("prop3", "value3") + chunk_received.wait + + # If we reach here, all chunks were received while async_block was running + }, + is_rsc_payload: false + ) + + # Collect chunks and signal after each one + chunks = [] + stream.each_chunk do |chunk| + chunks << chunk + chunk_received.signal + end + + # Verify all values were received + response_text = chunks.join + expect(response_text).to include("value1") + expect(response_text).to include("value2") + expect(response_text).to include("value3") + + # If this test completes without deadlock, it proves bidirectional streaming: + # - async_props_block sent chunks and waited for confirmation + # - each_chunk received chunks and signaled back while async_props_block was still running + # - This would deadlock if chunks weren't received concurrently + end + end + end +end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb new file mode 100644 index 0000000000..5a6953edc9 --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "react_on_rails_pro/async_props_emitter" + +RSpec.describe ReactOnRailsPro::AsyncPropsEmitter do + let(:bundle_timestamp) { "bundle-12345" } + # rubocop:disable RSpec/VerifiedDoubleReference + let(:request_stream) { instance_double("RequestStream") } + # rubocop:enable RSpec/VerifiedDoubleReference + let(:emitter) { described_class.new(bundle_timestamp, request_stream) } + + describe "#call" do + it "writes NDJSON update chunk with correct structure" do + allow(request_stream).to receive(:write) + + emitter.call("books", ["Book 1", "Book 2"]) + + expect(request_stream).to have_received(:write) do |output| + expect(output).to end_with("\n") + parsed = JSON.parse(output.chomp) + expect(parsed["bundleTimestamp"]).to eq(bundle_timestamp) + expect(parsed["updateChunk"]).to include('sharedExecutionContext.get("asyncPropsManager")') + expect(parsed["updateChunk"]).to include('asyncPropsManager.setProp("books", ["Book 1","Book 2"])') + end + end + + it "logs error and continues without raising when write fails" do + mock_logger = instance_double(Logger) + allow(Rails).to receive(:logger).and_return(mock_logger) + allow(request_stream).to receive(:write).and_raise(StandardError.new("Connection lost")) + allow(mock_logger).to receive(:error) + + expect { emitter.call("books", []) }.not_to raise_error + + expect(mock_logger).to have_received(:error) do |&block| + message = block.call + expect(message).to include("Failed to send async prop 'books'") + expect(message).to include("Connection lost") + end + end + end +end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb index 6c775ae799..45abb7e733 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb @@ -2,6 +2,9 @@ require_relative "spec_helper" require "fakefs/safe" +require "httpx" + +HTTPX::Plugins.load_plugin(:stream) describe ReactOnRailsPro::Request do let(:logger_mock) { instance_double(ActiveSupport::Logger).as_null_object } @@ -109,6 +112,13 @@ count: 1) do |yielder| yielder.call("Bundle not found\n") end + + # Mock the /upload-assets endpoint that gets called when send_bundle is true + upload_assets_url = "#{renderer_url}/upload-assets" + upload_request_info = mock_streaming_response(upload_assets_url, 200, count: 1) do |yielder| + yielder.call("Assets uploaded\n") + end + second_request_info = mock_streaming_response(render_full_url, 200) do |yielder| yielder.call("Hello, world!\n") end @@ -124,21 +134,33 @@ expect(first_request_info[:request].body.to_s).to include("renderingRequest=console.log") expect(first_request_info[:request].body.to_s).not_to include("bundle") - # Second request should have a bundle - # It's a multipart/form-data request, so we can access the form directly - second_request_body = second_request_info[:request].body.instance_variable_get(:@body) - second_request_form = second_request_body.instance_variable_get(:@form) + # The bundle should be sent via the /upload-assets endpoint + upload_request_body = upload_request_info[:request].body.instance_variable_get(:@body) + upload_request_form = upload_request_body.instance_variable_get(:@form) - expect(second_request_form).to have_key("bundle_server_bundle.js") - expect(second_request_form["bundle_server_bundle.js"][:body]).to be_a(FakeFS::Pathname) - expect(second_request_form["bundle_server_bundle.js"][:body].to_s).to eq(server_bundle_path) + expect(upload_request_form).to have_key("bundle_server_bundle.js") + expect(upload_request_form["bundle_server_bundle.js"][:body]).to be_a(FakeFS::Pathname) + expect(upload_request_form["bundle_server_bundle.js"][:body].to_s).to eq(server_bundle_path) + + # Second render request should also not have a bundle + expect(second_request_info[:request].body.to_s).to include("renderingRequest=console.log") + expect(second_request_info[:request].body.to_s).not_to include("bundle") end it "raises duplicate bundle upload error when server asks for bundle twice" do - first_request_info = mock_streaming_response(render_full_url, ReactOnRailsPro::STATUS_SEND_BUNDLE) do |yielder| + first_request_info = mock_streaming_response(render_full_url, ReactOnRailsPro::STATUS_SEND_BUNDLE, + count: 1) do |yielder| yielder.call("Bundle not found\n") end - second_request_info = mock_streaming_response(render_full_url, ReactOnRailsPro::STATUS_SEND_BUNDLE) do |yielder| + + # Mock the /upload-assets endpoint that gets called when send_bundle is true + upload_assets_url = "#{renderer_url}/upload-assets" + upload_request_info = mock_streaming_response(upload_assets_url, 200, count: 1) do |yielder| + yielder.call("Assets uploaded\n") + end + + second_request_info = mock_streaming_response(render_full_url, ReactOnRailsPro::STATUS_SEND_BUNDLE, + count: 1) do |yielder| yielder.call("Bundle still not found\n") end @@ -153,13 +175,17 @@ expect(first_request_info[:request].body.to_s).to include("renderingRequest=console.log") expect(first_request_info[:request].body.to_s).not_to include("bundle") - # Second request should have a bundle - second_request_body = second_request_info[:request].body.instance_variable_get(:@body) - second_request_form = second_request_body.instance_variable_get(:@form) + # The bundle should be sent via the /upload-assets endpoint + upload_request_body = upload_request_info[:request].body.instance_variable_get(:@body) + upload_request_form = upload_request_body.instance_variable_get(:@form) - expect(second_request_form).to have_key("bundle_server_bundle.js") - expect(second_request_form["bundle_server_bundle.js"][:body]).to be_a(FakeFS::Pathname) - expect(second_request_form["bundle_server_bundle.js"][:body].to_s).to eq(server_bundle_path) + expect(upload_request_form).to have_key("bundle_server_bundle.js") + expect(upload_request_form["bundle_server_bundle.js"][:body]).to be_a(FakeFS::Pathname) + expect(upload_request_form["bundle_server_bundle.js"][:body].to_s).to eq(server_bundle_path) + + # Second render request should also not have a bundle + expect(second_request_info[:request].body.to_s).to include("renderingRequest=console.log") + expect(second_request_info[:request].body.to_s).not_to include("bundle") end it "raises incompatible error when server returns incompatible error" do @@ -195,4 +221,102 @@ end end end + + # Unverified doubles are required for HTTPX bidirectional streaming because: + # 1. HTTPX::StreamResponse doesn't define status in its interface (causes verified double failures) + # 2. The :stream_bidi plugin adds methods (#write, #close, #build_request) not in standard interfaces + # 3. Using double(ClassName) documents the class while allowing interface flexibility + # rubocop:disable RSpec/VerifiedDoubles, RSpec/MultipleMemoizedHelpers + describe "render_code_with_incremental_updates" do + let(:js_code) { "console.log('incremental rendering');" } + let(:async_props_block) { proc { |_emitter| } } + let(:mock_request) { double(HTTPX::Request) } + let(:mock_response) { double(HTTPX::StreamResponse, status: 200) } + let(:mock_connection) { double(HTTPX::Session) } + + before do + allow(ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool).to receive_messages( + server_bundle_hash: "server_bundle.js", + rsc_bundle_hash: "rsc_bundle.js" + ) + + allow(mock_connection).to receive_messages(build_request: mock_request, request: mock_response) + allow(mock_request).to receive(:close) + allow(mock_request).to receive(:write) + allow(mock_response).to receive(:is_a?).with(HTTPX::ErrorResponse).and_return(false) + allow(mock_response).to receive(:each).and_yield("chunk\n") + allow(described_class).to receive(:incremental_connection).and_return(mock_connection) + + # Stub AsyncPropsEmitter to return a mock with end_stream_chunk + allow(ReactOnRailsPro::AsyncPropsEmitter).to receive(:new) do |_bundle_timestamp, _request| + double( + ReactOnRailsPro::AsyncPropsEmitter, + end_stream_chunk: { bundleTimestamp: "mocked", updateChunk: "mocked_js" } + ) + end + end + + it "creates NDJSON request with correct initial data" do + stream = described_class.render_code_with_incremental_updates( + "/render-incremental", + js_code, + async_props_block: async_props_block, + is_rsc_payload: false + ) + + stream.each_chunk(&:itself) + + expect(mock_connection).to have_received(:build_request).with( + "POST", + "/render-incremental", + headers: { "content-type" => "application/x-ndjson" }, + body: [] + ) + expect(mock_request).to have_received(:write).at_least(:once) + end + + it "spawns barrier.async task and passes emitter to async_props_block" do + emitter_received = nil + test_async_props_block = proc { |emitter| emitter_received = emitter } + + # Allow real emitter to be created for this test + allow(ReactOnRailsPro::AsyncPropsEmitter).to receive(:new).and_call_original + + stream = described_class.render_code_with_incremental_updates( + "/render-incremental", + js_code, + async_props_block: test_async_props_block, + is_rsc_payload: false + ) + + stream.each_chunk(&:itself) + + expect(emitter_received).to be_a(ReactOnRailsPro::AsyncPropsEmitter) + end + + it "uses rsc_bundle_hash when is_rsc_payload is true" do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(true) + + emitter_captured = nil + allow(ReactOnRailsPro::AsyncPropsEmitter).to receive(:new) do |bundle_timestamp, request_stream| + emitter_captured = { bundle_timestamp: bundle_timestamp, request_stream: request_stream } + double( + ReactOnRailsPro::AsyncPropsEmitter, + end_stream_chunk: { bundleTimestamp: bundle_timestamp, updateChunk: "mocked_js" } + ) + end + + stream = described_class.render_code_with_incremental_updates( + "/render-incremental", + js_code, + async_props_block: async_props_block, + is_rsc_payload: true + ) + + stream.each_chunk(&:itself) + + expect(emitter_captured[:bundle_timestamp]).to eq("rsc_bundle.js") + end + end + # rubocop:enable RSpec/VerifiedDoubles, RSpec/MultipleMemoizedHelpers end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_js_code_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_js_code_spec.rb new file mode 100644 index 0000000000..312eec41e1 --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_js_code_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "react_on_rails_pro/server_rendering_js_code" + +RSpec.describe ReactOnRailsPro::ServerRenderingJsCode do + describe ".async_props_setup_js" do + context "when async_props_block is NOT present in render_options" do + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: nil + ) + end + + it "returns empty string" do + result = described_class.async_props_setup_js(render_options) + + expect(result).to eq("") + end + end + + context "when async_props_block is present in render_options" do + let(:async_props_block) { proc { { data: "async_data" } } } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: async_props_block + ) + end + + it "returns JavaScript code that sets up AsyncPropsManager" do + result = described_class.async_props_setup_js(render_options) + + expect(result).to include("ReactOnRails.isRSCBundle") + expect(result).to include("ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps)") + expect(result).to include("propsWithAsyncProps") + expect(result).to include("asyncPropManager") + expect(result).to include('sharedExecutionContext.set("asyncPropsManager", asyncPropManager)') + expect(result).to include("usedProps = propsWithAsyncProps") + end + end + end + + describe ".render" do + let(:props_string) { '{"name":"Test"}' } + let(:rails_context) { '{"serverSide":true}' } + let(:redux_stores) { "" } + let(:react_component_name) { "TestComponent" } + + context "when async_props_block is present" do + let(:async_props_block) { proc { { data: "async_data" } } } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: async_props_block, + streaming?: false, + dom_id: "TestComponent-0", + trace: false + ) + end + + before do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:throw_js_errors).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:rendering_returns_promises).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:ssr_pre_hook_js).and_return(nil) + end + + it "includes async props setup JavaScript in the generated code" do + result = described_class.render( + props_string, + rails_context, + redux_stores, + react_component_name, + render_options + ) + + expect(result).to include("var usedProps = typeof props === 'undefined' ?") + expect(result).to include("ReactOnRails.isRSCBundle") + expect(result).to include("ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps)") + expect(result).to include('sharedExecutionContext.set("asyncPropsManager", asyncPropManager)') + end + end + + context "when async_props_block is NOT present" do + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: nil, + streaming?: false, + dom_id: "TestComponent-0", + trace: false + ) + end + + before do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:throw_js_errors).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:rendering_returns_promises).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:ssr_pre_hook_js).and_return(nil) + end + + it "does NOT include async props setup JavaScript in the generated code" do + result = described_class.render( + props_string, + rails_context, + redux_stores, + react_component_name, + render_options + ) + + expect(result).to include("var usedProps = typeof props === 'undefined' ?") + expect(result).not_to include("ReactOnRails.addAsyncPropsCapabilityToComponentProps") + expect(result).not_to include("asyncPropManager") + end + end + end +end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb new file mode 100644 index 0000000000..415d158211 --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module ReactOnRailsPro + module ServerRenderingPool + RSpec.describe NodeRenderingPool do + let(:js_code) { "console.log('test');" } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + request_digest: "abc123", + rsc_payload_streaming?: false + ) + end + + before do + allow(ReactOnRailsPro::ServerRenderingPool::ProRendering) + .to receive(:set_request_digest_on_render_options) + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(false) + allow(described_class).to receive(:server_bundle_hash).and_return("server123") + allow(described_class).to receive(:rsc_bundle_hash).and_return("rsc456") + end + + describe ".prepare_incremental_render_path" do + it "returns path with incremental-render endpoint" do + path = described_class.prepare_incremental_render_path(js_code, render_options) + + expect(path).to eq("/bundles/server123/incremental-render/abc123") + end + + context "when RSC support is enabled and rendering RSC payload" do + before do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(true) + allow(render_options).to receive(:rsc_payload_streaming?).and_return(true) + end + + it "uses RSC bundle hash instead of server bundle hash" do + path = described_class.prepare_incremental_render_path(js_code, render_options) + + expect(path).to eq("/bundles/rsc456/incremental-render/abc123") + end + end + end + + describe ".eval_streaming_js" do + context "when async_props_block is present in render_options" do + let(:async_props_block) { proc { { data: "async_data" } } } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + rsc_payload_streaming?: false, + internal_option: async_props_block + ) + end + + it "calls prepare_incremental_render_path and render_code_with_incremental_updates" do + expected_path = "/bundles/server123/incremental-render/abc123" + allow(described_class).to receive(:prepare_incremental_render_path) + .with(js_code, render_options) + .and_return(expected_path) + allow(ReactOnRailsPro::Request).to receive(:render_code_with_incremental_updates) + + described_class.eval_streaming_js(js_code, render_options) + + expect(described_class).to have_received(:prepare_incremental_render_path) + .with(js_code, render_options) + expect(ReactOnRailsPro::Request).to have_received(:render_code_with_incremental_updates) + .with(expected_path, js_code, async_props_block: async_props_block, is_rsc_payload: false) + end + end + + context "when async_props_block is NOT present" do + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + rsc_payload_streaming?: false, + internal_option: nil + ) + end + + it "calls prepare_render_path and render_code_as_stream" do + expected_path = "/bundles/server123/render/abc123" + allow(described_class).to receive(:prepare_render_path) + .with(js_code, render_options) + .and_return(expected_path) + allow(ReactOnRailsPro::Request).to receive(:render_code_as_stream) + + described_class.eval_streaming_js(js_code, render_options) + + expect(described_class).to have_received(:prepare_render_path) + .with(js_code, render_options) + expect(ReactOnRailsPro::Request).to have_received(:render_code_as_stream) + .with(expected_path, js_code, is_rsc_payload: false) + end + end + end + end + end +end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb index c8a12e5b0c..f3d8e84963 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb @@ -2,6 +2,10 @@ require_relative "spec_helper" require "react_on_rails_pro/stream_request" +require "async/barrier" +require "httpx" + +HTTPX::Plugins.load_plugin(:stream) RSpec.describe ReactOnRailsPro::StreamRequest do describe ".create" do @@ -10,4 +14,44 @@ expect(result).to be_a(ReactOnRailsPro::StreamDecorator) end end + + # Unverified doubles are required for streaming responses because: + # 1. HTTP streaming responses don't have a dedicated class type in HTTPX + # 2. The #each method for streaming is added dynamically at runtime + # 3. The interface varies based on the streaming mode (HTTP/2, chunked, etc.) + # rubocop:disable RSpec/VerifiedDoubles + describe "#each_chunk with barrier" do + it "passes barrier to request_executor block" do + barrier_received = nil + mock_response = double(HTTPX::StreamResponse, status: 200) + allow(mock_response).to receive(:is_a?).with(HTTPX::ErrorResponse).and_return(false) + allow(mock_response).to receive(:each).and_yield("chunk\n") + + stream = described_class.create do |_send_bundle, barrier| + barrier_received = barrier + mock_response + end + + stream.each_chunk(&:itself) + + expect(barrier_received).to be_a(Async::Barrier) + end + + it "calls barrier.wait after yielding chunks" do + barrier = Async::Barrier.new + allow(Async::Barrier).to receive(:new).and_return(barrier) + expect(barrier).to receive(:wait) + + mock_response = double(HTTPX::StreamResponse, status: 200) + allow(mock_response).to receive(:is_a?).with(HTTPX::ErrorResponse).and_return(false) + allow(mock_response).to receive(:each).and_yield("chunk\n") + + stream = described_class.create do |_send_bundle, _barrier| + mock_response + end + + stream.each_chunk(&:itself) + end + end + # rubocop:enable RSpec/VerifiedDoubles end