Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
544b060
upload assets in a separate request when needed
AbanoubGhadban Jun 30, 2025
91a737b
add ndjson end point to accept the rendering request in chunks
AbanoubGhadban Aug 10, 2025
33f8d20
Implement Incremental Render Request Manager and Bundle Validation
AbanoubGhadban Aug 11, 2025
64feb4d
WIP: handle errors happen during incremental rendering
AbanoubGhadban Aug 11, 2025
9f13f75
handle errors happen at the InrecementalRequestManager
AbanoubGhadban Aug 13, 2025
4e67128
replace pending operations with content buffer
AbanoubGhadban Aug 13, 2025
f85a2ec
Refactor incremental rendering to use a new function stream handler
AbanoubGhadban Aug 14, 2025
56f2d8d
Enhance error handling in incremental rendering stream
AbanoubGhadban Aug 14, 2025
0fb3616
Refactor incremental render tests for improved readability and mainta…
AbanoubGhadban Aug 14, 2025
4da48c5
create a test to test the streaming from server to client
AbanoubGhadban Aug 15, 2025
17d12b5
Refactor incremental render tests to use custom waitFor function
AbanoubGhadban Aug 15, 2025
5de0e28
Enhance incremental render tests with helper functions for setup and …
AbanoubGhadban Aug 15, 2025
af1a9bd
Remove unnecessary console logs from worker and test files
AbanoubGhadban Aug 15, 2025
ee63053
Refactor incremental render tests to use jest mock functions for sink…
AbanoubGhadban Aug 15, 2025
af2f6c8
add echo server test and enhance error reporting in waitFor function
AbanoubGhadban Aug 15, 2025
b234849
Refactor incremental rendering logic and enhance bundle validation
AbanoubGhadban Aug 15, 2025
78204d5
Revert "Refactor incremental rendering logic and enhance bundle valid…
AbanoubGhadban Aug 18, 2025
2a451ec
Refactor incremental render request handling and improve error manage…
AbanoubGhadban Aug 18, 2025
34a4fe0
Refactor request handling by consolidating prechecks
AbanoubGhadban Aug 19, 2025
7457454
make asset-exists endpoint check authentication only
AbanoubGhadban Aug 20, 2025
afb3ae6
linting
AbanoubGhadban Aug 20, 2025
09b942f
Enhance asset upload handling to support bundles
AbanoubGhadban Aug 20, 2025
0f2511c
Enhance tests for asset upload handling
AbanoubGhadban Aug 20, 2025
caaddb6
Add test for asset upload with bundles in hash directories
AbanoubGhadban Aug 20, 2025
586fe35
Add incremental render endpoint tests
AbanoubGhadban Aug 21, 2025
e82109c
Refactor and enhance incremental render endpoint tests
AbanoubGhadban Aug 21, 2025
81ec0d6
make buildVM returns the built vm
AbanoubGhadban Sep 5, 2025
e8de740
Refactor VM handling and introduce ExecutionContext
AbanoubGhadban Sep 5, 2025
2a036e3
Fix runOnOtherBundle function parameters and improve global context h…
AbanoubGhadban Sep 9, 2025
961d166
Refactor incremental render handling and improve error management
AbanoubGhadban Sep 9, 2025
7605907
Enhance incremental render functionality and improve test coverage
AbanoubGhadban Sep 9, 2025
8a391e7
tmp
AbanoubGhadban Oct 22, 2025
8170fd8
Fix incremental render tests (#2032)
AbanoubGhadban Nov 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion react_on_rails_pro/lib/react_on_rails_pro/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ def render_code_as_stream(path, js_code, is_rsc_payload:)
end

ReactOnRailsPro::StreamRequest.create do |send_bundle|
form = form_with_code(js_code, send_bundle)
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def generate_rsc_payload_js_function(render_options)
renderingRequest,
rscBundleHash: '#{ReactOnRailsPro::Utils.rsc_bundle_hash}',
}
const runOnOtherBundle = globalThis.runOnOtherBundle;
if (typeof generateRSCPayload !== 'function') {
globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
Expand Down
27 changes: 27 additions & 0 deletions react_on_rails_pro/packages/node-renderer/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as errorReporter from './errorReporter';
import { getConfig } from './configBuilder';
import log from './log';
import type { RenderResult } from '../worker/vm';
import fileExistsAsync from './fileExistsAsync';

export const TRUNCATION_FILLER = '\n... TRUNCATED ...\n';

Expand Down Expand Up @@ -170,3 +171,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<ResponseResult | null> {
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;
}
204 changes: 157 additions & 47 deletions react_on_rails_pro/packages/node-renderer/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@ import log, { sharedLoggerOptions } from './shared/log';
import packageJson from './shared/packageJson';
import { buildConfig, Config, getConfig } from './shared/configBuilder';
import fileExistsAsync from './shared/fileExistsAsync';
import type { FastifyInstance, FastifyReply, FastifyRequest } from './worker/types';
import checkProtocolVersion from './worker/checkProtocolVersionHandler';
import authenticate from './worker/authHandler';
import { handleRenderRequest, type ProvidedNewBundle } from './worker/handleRenderRequest';
import type { FastifyInstance, FastifyReply } from './worker/types';
import { performRequestPrechecks } from './worker/requestPrechecks';
import { AuthBody, authenticate } from './worker/authHandler';
import {
handleRenderRequest,
type ProvidedNewBundle,
handleNewBundlesProvided,
} from './worker/handleRenderRequest';
import handleGracefulShutdown from './worker/handleGracefulShutdown';
import {
handleIncrementalRenderRequest,
type IncrementalRenderInitialRequest,
type IncrementalRenderSink,
} from './worker/handleIncrementalRenderRequest';
import { handleIncrementalRenderStream } from './worker/handleIncrementalRenderStream';
import {
errorResponseResult,
formatExceptionMessage,
Expand Down Expand Up @@ -163,41 +173,11 @@ export default function run(config: Partial<Config>) {
},
});

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
Expand All @@ -212,7 +192,9 @@ export default function run(config: Partial<Config>) {
// 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;
}

Expand Down Expand Up @@ -254,7 +236,7 @@ export default function run(config: Partial<Config>) {
providedNewBundles,
assetsToCopy,
});
await setResponse(result, res);
await setResponse(result.response, res);
} catch (err) {
const exceptionMessage = formatExceptionMessage(
renderingRequest,
Expand All @@ -272,17 +254,124 @@ export default function run(config: Partial<Config>) {
}
});

// 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<string, unknown>) : {};

// 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<Record<string, unknown>, 'dependencyBundleTimestamps'>,
'dependencyBundleTimestamps',
);

const initial: IncrementalRenderInitialRequest = {
renderingRequest: String((tempReqBody as { renderingRequest?: string }).renderingRequest ?? ''),
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: (obj: unknown) => {
if (!incrementalSink) {
log.error({ msg: 'Unexpected update chunk received after rendering was aborted', obj });
return;
}

try {
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: () => {
// Do nothing
},
});
} 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<Record<string, Asset>, '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');
Expand All @@ -294,7 +383,9 @@ export default function run(config: Partial<Config>) {
}

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');
Expand Down Expand Up @@ -333,7 +424,24 @@ export default function run(config: Partial<Config>) {

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(
Expand All @@ -344,7 +452,7 @@ export default function run(config: Partial<Config>) {
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,
Expand Down Expand Up @@ -376,7 +484,9 @@ export default function run(config: Partial<Config>) {
Querystring: { filename: string };
Body: WithBodyArrayField<Record<string, unknown>, '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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
*/
// TODO: Replace with fastify-basic-auth per https://github.com/shakacode/react_on_rails_pro/issues/110

import type { FastifyRequest } from './types';
import { getConfig } from '../shared/configBuilder';

export = function authenticate(req: FastifyRequest) {
export interface AuthBody {
password?: string;
}

export function authenticate(body: AuthBody) {
const { password } = getConfig();

if (password && password !== (req.body as { password?: string }).password) {
if (password && password !== body.password) {
return {
headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' },
status: 401,
Expand All @@ -21,4 +24,4 @@ export = function authenticate(req: FastifyRequest) {
}

return undefined;
};
}
Loading
Loading