Skip to content

Isolate Pro runtime and node-renderer behavior changes#2895

Closed
justin808 wants to merge 1 commit into
mainfrom
split/pro-runtime-renderer
Closed

Isolate Pro runtime and node-renderer behavior changes#2895
justin808 wants to merge 1 commit into
mainfrom
split/pro-runtime-renderer

Conversation

@justin808
Copy link
Copy Markdown
Member

@justin808 justin808 commented Mar 30, 2026

Summary

  • split Pro runtime and node-renderer behavior changes out of the giant migration branch
  • include node-renderer request/upload handling updates and related tanstack-router integration adjustments
  • include corresponding Pro helper/config/request updates and tests

Scope

  • packages/react-on-rails-pro-node-renderer/**
  • packages/react-on-rails-pro/src/tanstack-router/**
  • packages/react-on-rails-pro/tests/tanstackRouter.test.ts
  • react_on_rails/lib/react_on_rails/helper.rb
  • react_on_rails_pro/{app/helpers,lib,spec}/**

Testing

  • pnpm --filter react-on-rails-pro test
  • pnpm --filter react-on-rails-pro-node-renderer exec jest tests/configBuilder.test.ts tests/uploadRaceCondition.test.ts tests/worker.test.ts --runInBand
  • bundle exec rubocop (via hooks and targeted run) ✅
  • bundle exec rspec react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb react_on_rails_pro/spec/react_on_rails_pro/stream_integration_spec.rb react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb ⚠️
    • local environment missing async gem (LoadError: cannot load such file -- async)

Note

Medium Risk
Changes Node renderer request semantics (/upload-assets now requires targetBundles) and relaxes renderer password enforcement, which can affect deployment security and compatibility with older clients. Also adjusts TanStack Router SSR flags and streaming async primitives, touching hydration and response streaming behavior.

Overview
Node renderer upload/render handling is updated for protocol 2.0.0. /upload-assets now requires a targetBundles field and copies uploaded assets into each target bundle directory under per-bundle locks, using Promise.allSettled to avoid races with request upload-dir cleanup; related tests are updated to stop sending bundle_<hash> fields for uploads.

Renderer configuration and auth defaults are loosened/simplified. configBuilder drops production-like password enforcement/validation and simplifies env/default resolution and log sanitization (password is masked when present; no special empty-string handling), and replayServerAsyncOperationLogs defaulting is now based on NODE_ENV.

TanStack Router SSR integration is adjusted. Both server render and client hydration now set router.ssr = true (with a server-side compatibility check) to reduce hydration mismatches, and legacy logic that temporarily set/cleared an object-shaped ssr flag is removed; tests are updated accordingly.

Ruby Pro streaming and config behavior are updated. Streaming switches from Async::Promise to Async::Variable for first-chunk handoff and error propagation, renderer password production validation is removed from Pro configuration, and helper/request code and specs are aligned; a couple of documentation links are updated.

Written by Cursor Bugbot for commit e7ca7a5. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling and propagation in streaming responses
    • Enhanced SSR mode initialization for TanStack Router hydration
  • Refactor

    • Simplified configuration handling and validation
    • Updated asset upload mechanism to use targetBundles parameter for bundle targeting

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 30, 2026

Walkthrough

This PR removes production-like password validation and fail-closed behavior, refactors bundle/asset upload endpoints to explicitly require targetBundles instead of deriving targets from dynamic form fields, replaces Async::Promise with Async::Variable for streaming synchronization, and updates TanStack Router's SSR flag type to support both boolean and object forms while simplifying hydration/render flows.

Changes

Cohort / File(s) Summary
Node Renderer Dependency
packages/react-on-rails-pro-node-renderer/package.json
Updated fastify dependency version range from ^5.8.3 to ^5.8.1.
Node Renderer Configuration Cleanup
packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts
Removed production-like password validation logic, fail-closed error paths, and environment normalization helpers. Simplified password masking semantics and replaced "env-backed values may lag" logging with simplified "Default settings" message. buildConfig() now straightforward merge without preserved-password behavior.
Node Renderer Upload & Bundle Handling
packages/react-on-rails-pro-node-renderer/src/worker.ts, packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts
Removed shared extractBundlesAndAssets helper and inlined bundle vs asset classification. Updated /upload-assets to require explicit targetBundles field instead of deriving from bundle_<hash> form fields. Replaced handleNewBundlesProvided call with per-bundle directory creation, locking, and asset-copy logic using Promise.allSettled. Renamed requestContext parameter to renderingRequest and made handleNewBundlesProvided non-exported.
Node Renderer Test Updates
packages/react-on-rails-pro-node-renderer/tests/configBuilder.test.ts, packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts, packages/react-on-rails-pro-node-renderer/tests/worker.test.ts
Simplified configBuilder tests by removing password validation, production-mode failure, and replayServerAsyncOperationLogs defaulting specs. Updated upload tests to use targetBundles: [...] instead of bundle_<hash> form fields. Removed backward-compatibility test for legacy bundle field handling.
TanStack Router SSR Mode
packages/react-on-rails-pro/src/tanstack-router/types.ts, packages/react-on-rails-pro/src/tanstack-router/serverRender.ts, packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Updated TanStackRouter.ssr type from { manifest?: unknown } to boolean | { manifest: unknown }. Added enableRouterSsrMode helper to set router.ssr = true in server render. Changed hydration to keep router.ssr = true during hydration instead of legacy object-based flag, and removed post-hydration SSR flag cleanup.
TanStack Router Tests
packages/react-on-rails-pro/tests/tanstackRouter.test.ts
Removed local compatAct helper and two legacy hydration path tests. Updated assertions: client hydration without SSR payload now asserts router.ssr is not true; server render test now expects router.ssr === true during async rendering.
React on Rails Pro Ruby Configuration
react_on_rails_pro/lib/react_on_rails_pro/configuration.rb, react_on_rails/lib/react_on_rails/helper.rb
Removed production-like validate_renderer_password_for_production method and its fail-closed error behavior. Updated error message URLs in React component helpers to point to correct fixture files.
React on Rails Pro Streaming Synchronization
react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb, react_on_rails_pro/lib/react_on_rails_pro/request.rb
Replaced Async::Promise with Async::Variable in consumer_stream_async for first-chunk synchronization; error handling now sets variable value instead of rejecting promise. Updated stream-chunk processing to assign variable value instead of calling promise resolve. Simplified asset upload comments.
React on Rails Pro Streaming Tests
react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb, react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb, react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb, react_on_rails_pro/spec/react_on_rails_pro/stream_integration_spec.rb
Removed password validation configuration specs and pre-commit exception path test. Updated async dependency from async/promise to async/variable in specs. Simplified helper tests by removing timeout wrapper around barrier wait.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

review-needed, full-ci

Suggested reviewers

  • ihabadham
  • alexeyr-ci2

Poem

🐰 Bundles hop through cleaner paths, no passwords guard the gate,
SSR flags now simply true, the async streams await,
Variable by variable, we synchronize the flow,
Validation cast away, let the renderers go! 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Isolate Pro runtime and node-renderer behavior changes' clearly and accurately describes the main objective of the pull request—splitting Pro runtime and node-renderer behavior changes from a larger migration branch.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split/pro-runtime-renderer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@@ -56,7 +57,7 @@ export interface Config {
// Number of workers that will be forked to serve rendering requests.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security regression: This doc-comment change reflects the removal of validatePasswordForProduction below. Previously the renderer refused to start in production-like environments without a password, providing a fail-closed security guarantee. Now it silently runs unauthenticated.

If this relaxation is intentional (e.g. delegating enforcement to infrastructure), please add a prominent warning here and in the docs so operators don't accidentally run the renderer unauthenticated in production.

Suggested change
// Number of workers that will be forked to serve rendering requests.
// If no password is set, no authentication will be required.
// WARNING: Without a password, any client that can reach this renderer can trigger
// server-side rendering. Always protect the renderer with network-level access controls
// (firewall rules, VPN, etc.) when running without a password in non-development environments.


// Default to true in development, otherwise it is set to false.
replayServerAsyncOperationLogs: defaultReplayServerAsyncOperationLogs(),
// default to true in development, otherwise it is set to false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module-load-time evaluation of NODE_ENV: NODE_ENV is captured once at module load (const NODE_ENV = env.NODE_ENV || 'production'), and defaultConfig is evaluated at module load too. The old code explicitly re-evaluated defaultReplayServerAsyncOperationLogs() inside buildConfig to handle env vars set after import.

The previous test suite verified "does not throw when password is set after module import" — now that env vars are baked in at load time, this scenario silently uses stale values. This is likely fine for production but can cause confusing behaviour in test setups that mutate process.env between require() and buildConfig().

Suggested change
// default to true in development, otherwise it is set to false
// default to true in development, otherwise it is set to false.
// NOTE: evaluated at module load time; mutating NODE_ENV after import has no effect.
replayServerAsyncOperationLogs: truthy(
env.REPLAY_SERVER_ASYNC_OPERATION_LOGS ?? NODE_ENV === 'development',
),

password: aConfig.password != null ? '<MASKED>' : defaultValue,
allWorkersRestartInterval: aConfig.allWorkersRestartInterval || defaultValue,
delayBetweenIndividualWorkerRestarts: aConfig.delayBetweenIndividualWorkerRestarts || defaultValue,
gracefulWorkerRestartTimeout: aConfig.gracefulWorkerRestartTimeout || defaultValue,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnostic regression: The old code distinguished undefined, '', and a real password in logs (<NOT PROVIDED>, <EMPTY STRING>, <MASKED>). The new code collapses undefined and '' together since '' != null is true — an empty string now shows as <MASKED> instead of <EMPTY STRING>. This makes it harder to spot misconfigured-but-blank passwords during debugging.

Suggested change
gracefulWorkerRestartTimeout: aConfig.gracefulWorkerRestartTimeout || defaultValue,
password: aConfig.password === undefined ? defaultValue : aConfig.password === '' ? '<EMPTY STRING>' : '<MASKED>',

),
'Default settings': defaultConfig,
'ENV values used for settings (use "RENDERER_" prefix)': envValuesUsed(),
'Customized values for settings from config object (overrides ENV)': sanitizedSettings(userConfig),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default settings logged unsanitized: defaultConfig is logged directly now. As long as defaultConfig.password is undefined this is fine, but if the field ever references env.RENDERER_PASSWORD at module load the password would appear in plain text in logs. Worth keeping sanitizedSettings(defaultConfig, ...) here for defence in depth.

if (key === 'bundle') {
assertAsset(value, key);
providedNewBundles.push({ timestamp: bundleTimestamp, bundle: value });
} else if (key.startsWith('bundle_')) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent empty timestamp for bundle_ field: The old extractBundlesAndAssets warned when key === 'bundle_' (no hash suffix). The replacement silently uses key.replace('bundle_', '') which yields '' for that degenerate key, creating a bundle entry with an empty timestamp string. This could cause subtle filesystem issues (a bundle directory named ''). Consider at least logging a warning:

Suggested change
} else if (key.startsWith('bundle_')) {
} else if (key.startsWith('bundle_')) {
const timestamp = key.slice('bundle_'.length);
if (!timestamp) {
log.warn('Received form field "bundle_" with no hash suffix — ignoring (possible Ruby client bug)');
} else {
assertAsset(value, key);
providedNewBundles.push({ timestamp, bundle: value });
}

"@fastify/formbody": "^7.4.0 || ^8.0.2",
"@fastify/multipart": "^8.3.1 || ^9.0.3",
"fastify": "^5.8.3",
"fastify": "^5.8.1",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency downgrade: fastify is being lowered from ^5.8.3 to ^5.8.1. ^ ranges allow any compatible version at install time, so npm/pnpm could now resolve 5.8.1 or 5.8.2 — versions that were previously excluded. If this was intentional (e.g. to avoid a regression introduced in 5.8.2/5.8.3), please add a comment explaining why. Otherwise this looks like it should stay at ^5.8.3.

import { normalizeSearch } from './utils.ts';

/**
* Enables TanStack Router's internal SSR mode and verifies the flag is writable.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compatibility check has a logic gap: routerWithSsrFlag.ssr = true then if (!routerWithSsrFlag.ssr) — if the router normalises the value internally (e.g. stores { manifest: undefined } when given true), the truthiness check passes but the actual stored value differs from what you intended. You'd want to verify the flag was accepted as true, not just truthy:

Suggested change
* Enables TanStack Router's internal SSR mode and verifies the flag is writable.
function enableRouterSsrMode(router: TanStackRouter): void {
const routerWithSsrFlag = router;
routerWithSsrFlag.ssr = true;
if (routerWithSsrFlag.ssr !== true) {
throw new Error(
'react-on-rails-pro/tanstack-router: Expected router.ssr to accept a boolean flag. ' +
'Please check that your @tanstack/react-router version is compatible.',
);
}
}

Comment on lines +456 to 465
# BEFORE the response is committed, enabling a proper HTTP redirect.
# Do NOT re-raise here: the caller owns the error now.
rescue StandardError
# Variable was already resolved — the first chunk was returned successfully.
# This is a post-first-chunk error. Re-raise so barrier.wait propagates it
# (the response is already committed at that point, so only JS redirect is possible).
raise e
end
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

raise e loses the original backtrace: Using raise e re-raises the exception but wraps it with a new backtrace anchored at this rescue block, hiding the original call site. Use bare raise to preserve the original backtrace:

Suggested change
# BEFORE the response is committed, enabling a proper HTTP redirect.
# Do NOT re-raise here: the caller owns the error now.
rescue StandardError
# Variable was already resolved — the first chunk was returned successfully.
# This is a post-first-chunk error. Re-raise so barrier.wait propagates it
# (the response is already committed at that point, so only JS redirect is possible).
raise e
end
end
rescue StandardError
# Variable was already resolved — the first chunk was returned successfully.
# This is a post-first-chunk error. Re-raise so barrier.wait propagates it
# (the response is already committed at that point, so only JS redirect is possible).
raise

result = first_chunk_var.wait

# If the async task stored an exception (pre-first-chunk error), raise it now.
# This happens BEFORE response.stream.write(template_string) in
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async::Variable raises RuntimeError on double-write: The pattern here relies on the gem raising a StandardError subclass when the variable is already resolved so the inner rescue StandardError can detect the "already resolved" case. This works today because Async::Variable raises RuntimeError (< StandardError). However this is an undocumented internal behaviour — if the gem ever changes to raise a non-StandardError (e.g. Async::Stop) the inner rescue would silently swallow the original error instead of re-raising it. Consider asserting the variable is not yet resolved rather than relying on the exception type, or at least add a comment pinning the gem version dependency.

@@ -411,7 +411,7 @@ def mock_request_and_response(mock_chunks = chunks, count: 1)
expect(initial_result).to include(react_component_div_with_initial_chunk)

# Wait for async task to complete
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed Async::Task.current.with_timeout: The earlier with_timeout(5) prevented the test from hanging indefinitely if the async task never completes. Removing it means a bug in the streaming path could cause this test to hang forever in CI. Consider restoring the timeout or using RSpec's built-in timeout helpers.

@@ -626,26 +626,6 @@ def setup_stream_test(component_count: 2)
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed pre-first-chunk error propagation test: The deleted test "does not commit the response when render_to_string raises" verified that a shell-level error before the first chunk reaches the caller before the response is committed — enabling a proper HTTP redirect. This is one of the trickiest code paths in the streaming implementation, and the new Async::Variable-based approach changes the error propagation mechanism. Please add an equivalent test for the Async::Variable path to verify:

  1. A pre-first-chunk exception is raised synchronously in consumer_stream_async
  2. response.stream.write has NOT been called at that point

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Default config logged with plaintext password
    • logSanitizedConfig now logs defaultConfig through sanitizedSettings(..., '<NOT PROVIDED AT MODULE LOAD>') so passwords are masked again.
  • ✅ Fixed: Assets not copied when bundle move fails
    • copyUploadedAssets was moved to run after the bundle move try/catch so assets are copied even when move failure is handled as an existing-bundle race.

Create PR

Or push these changes by commenting:

@cursor push f8f98d0b63
Preview (f8f98d0b63)
diff --git a/packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts b/packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts
--- a/packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts
+++ b/packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts
@@ -241,7 +241,7 @@
   log.info({
     'Node Renderer version': packageJson.version,
     'Protocol version': packageJson.protocolVersion,
-    'Default settings': defaultConfig,
+    'Default settings': sanitizedSettings(defaultConfig, '<NOT PROVIDED AT MODULE LOAD>'),
     'ENV values used for settings (use "RENDERER_" prefix)': envValuesUsed(),
     'Customized values for settings from config object (overrides ENV)': sanitizedSettings(userConfig),
     'Final renderer settings': sanitizedSettings(config, '<NOT PROVIDED>'),

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
--- a/packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts
+++ b/packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts
@@ -107,10 +107,6 @@
         `Moving uploaded file ${providedNewBundle.bundle.savedFilePath} to ${bundleFilePathPerTimestamp}`,
       );
       await moveUploadedAsset(providedNewBundle.bundle, bundleFilePathPerTimestamp);
-      if (assetsToCopy) {
-        await copyUploadedAssets(assetsToCopy, bundleDirectory);
-      }
-
       log.info(
         `Completed moving uploaded file ${providedNewBundle.bundle.savedFilePath} to ${bundleFilePathPerTimestamp}`,
       );
@@ -131,6 +127,9 @@
         bundleFilePathPerTimestamp,
       );
     }
+    if (assetsToCopy) {
+      await copyUploadedAssets(assetsToCopy, bundleDirectory);
+    }
 
     return undefined;
   } finally {

You can send follow-ups to this agent here.

defaultConfig,
'<NOT PROVIDED AT MODULE LOAD>',
),
'Default settings': defaultConfig,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default config logged with plaintext password

High Severity

logSanitizedConfig now logs defaultConfig directly instead of passing it through sanitizedSettings(). Since defaultConfig.password holds the raw env.RENDERER_PASSWORD value, the plaintext password will appear in logs. The old code used sanitizedSettings(defaultConfig, '<NOT PROVIDED AT MODULE LOAD>') to mask it.

Additional Locations (1)
Fix in Cursor Fix in Web

await moveUploadedAsset(providedNewBundle.bundle, bundleFilePathPerTimestamp);
if (assetsToCopy) {
await copyUploadedAssets(assetsToCopy, bundleDirectory);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assets not copied when bundle move fails

Medium Severity

copyUploadedAssets was moved inside the try block that wraps moveUploadedAsset. If the move throws (e.g., because another worker already wrote the bundle), the catch block handles it gracefully but assets are never copied. The old code intentionally placed asset copying after this try/catch so assets were always copied, even when the bundle already existed.

Fix in Cursor Fix in Web

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Mar 30, 2026

Critical: Security regression. validatePasswordForProduction (TS) and validate_renderer_password_for_production (Ruby) are deleted. The renderer now starts without a password in any environment. The old behavior was fail-closed; the new behavior is fail-open. If intentional, add: a prominent doc comment explaining the changed security model and required mitigations (firewall, VPN, mTLS), a CHANGELOG entry flagging the breaking security change, and a runtime warning log when starting without a password in a non-development NODE_ENV.

Medium: Protocol breaking change. The /upload-assets endpoint now requires targetBundles and drops the bundle_hash form key convention. Document the protocol 2.0.0 bump in the upgrade guide so users running the Node renderer and Rails gem at different versions get a clear error rather than a silent asset-copy failure.

Medium: Critical test coverage removed. The pre-first-chunk error propagation tests are deleted with no equivalent test for the Async::Variable path. This is one of the trickiest code paths in the codebase. See inline comment on stream_spec.rb.

Medium: Backtrace lost on re-raise. The inner rescue block in consumer_stream_async uses raise e instead of bare raise, which replaces the original exception backtrace. See inline comment on react_on_rails_pro_helper.rb.

Minor items (see inline comments for details): fastify downgraded from 5.8.3 to 5.8.1 range, needs comment if intentional; bundle_ key without hash silently creates empty-timestamp bundle entry; sanitizedSettings no longer distinguishes empty-string from real passwords; router.ssr check uses falsy rather than strict equality; with_timeout removed from specs leaving bare @async_barrier.wait that can hang CI.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 30, 2026

Greptile Summary

This PR isolates two categories of changes from a larger migration branch: (1) the node-renderer protocol update that replaces bundle_<hash> binary uploads on /upload-assets with a targetBundles hash-list field, and (2) a simplification of TanStack Router SSR hydration that unifies the router.ssr flag to a plain boolean true on both server and client paths.

Key changes:

  • /upload-assets endpoint no longer receives actual bundle files — it now only copies shared assets into pre-existing bundle directories identified by targetBundles; the render endpoint remains the only path that moves a bundle binary.
  • Async::Promise (deprecated in async gem v2.29) replaced with Async::Variable for streaming error propagation in the Ruby Pro helper.
  • Both Node-side (validatePasswordForProduction) and Ruby-side (validate_renderer_password_for_production) fail-closed password enforcement removed; password is now fully optional in all environments.
  • logSanitizedConfig was simplified by logging defaultConfig directly — this inadvertently removes the sanitizedSettings() wrapper that masked RENDERER_PASSWORD, potentially logging the secret in plaintext.
  • handleNewBundleProvided moves asset-copying inside the try-block, which silently skips it on the bundle-already-exists race-condition path.
  • Several tests covering the production password requirement, pre-first-chunk error propagation, and response-commit guards were removed; the PR acknowledges the async gem was unavailable for local test runs.

Confidence Score: 4/5

Mostly safe to merge once the password-plaintext logging regression in logSanitizedConfig is addressed; the other findings are behavioral trade-offs or test gaps that can be followed up.

One P1 finding remains: logSanitizedConfig now logs defaultConfig directly instead of through sanitizedSettings(), which exposes RENDERER_PASSWORD in plaintext whenever the startup config dump runs. The remaining P2 items (asset-copy skip on race-condition path, deleted error-path tests, removal of production password guards) are lower-risk behavioral trade-offs or test debt, none of which break the primary render path.

packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts (plaintext password logging); packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts (asset-copy skip on race path); react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb and react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb (deleted error-path tests).

Important Files Changed

Filename Overview
packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts Removes production password validation and normalizedRuntimeEnvs helpers; introduces P1 regression where logSanitizedConfig logs defaultConfig (including RENDERER_PASSWORD) in plaintext instead of calling sanitizedSettings().
packages/react-on-rails-pro-node-renderer/src/worker.ts Reworks /upload-assets endpoint to accept targetBundles (hash list) instead of bundle_<hash> file uploads; asset copies now use per-bundle locks and Promise.allSettled.
packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts Asset copying moved inside the try-block; skipped when bundle already exists (race-condition catch path) — removes the intentional idempotent "always copy" guarantee from the old code.
react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb Migrates streaming error propagation from Async::Promise (deprecated in async v2.29) to Async::Variable; uses value-based error detection (result.is_a?(StandardError)) instead of promise rejection.
react_on_rails_pro/lib/react_on_rails_pro/configuration.rb Removes validate_renderer_password_for_production and the fail-closed production password check; password is now optional in all environments.
react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb Switches require "async/promise" to require "async/variable"; drops "does not commit the response when render_to_string raises" test without replacement.
packages/react-on-rails-pro-node-renderer/tests/configBuilder.test.ts Removes all password-validation tests (production fail-closed, mixed-case env, module-load password masking) along with the mockProcessExit helper; only RENDERER_HOST env teardown remains.

Sequence Diagram

sequenceDiagram
    participant Rails as Rails Client
    participant NR as Node Renderer Worker
    participant FS as File System

    Note over Rails,FS: Render request (bundle + assets)
    Rails->>NR: POST /:bundleTimestamp (multipart: bundle file + assets)
    NR->>FS: moveUploadedAsset(bundle → bundleDirectory)
    NR->>FS: copyUploadedAssets(assets → bundleDirectory)
    NR-->>Rails: render result

    Note over Rails,FS: Upload-assets request (NEW protocol — assets only)
    Rails->>NR: POST /upload-assets (targetBundles: [hash1, hash2], asset files)
    loop per bundleTimestamp in targetBundles
        NR->>FS: mkdir(bundleDirectory)
        NR->>NR: lock(bundleFilePath)
        NR->>FS: copyUploadedAssets(assets → bundleDirectory)
        NR->>NR: unlock
    end
    NR-->>Rails: 200 OK
Loading

Comments Outside Diff (1)

  1. react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb, line 520-543 (link)

    P2 Critical error-propagation test removed without replacement

    The test "propagates pre-first-chunk errors to the caller" (removed in this PR) covered the path where an error occurs before the first chunk is delivered — the most important case because it determines whether the response can be redirected. Similarly, stream_spec.rb dropped "does not commit the response when render_to_string raises".

    The Async::Promise → Async::Variable migration legitimately requires updating these tests, but they were deleted outright rather than adapted, leaving the critical pre-commit error path without automated coverage.

    The PR notes the async gem was unavailable locally (⚠️). Consider restoring these before merging:

    it "propagates pre-first-chunk errors to the caller" do
      allow(self).to receive(:internal_stream_react_component)
        .and_raise(StandardError, "node renderer crashed before first chunk")
    
      expect do
        stream_react_component(component_name, props: props, **component_options)
      end.to raise_error(StandardError, "node renderer crashed before first chunk")
    end

Reviews (1): Last reviewed commit: "Isolate Pro runtime and node-renderer be..." | Re-trigger Greptile

Comment on lines +244 to 248
'Default settings': defaultConfig,
'ENV values used for settings (use "RENDERER_" prefix)': envValuesUsed(),
'Customized values for settings from config object (overrides ENV)': sanitizedSettings(userConfig),
'Final renderer settings': sanitizedSettings(config, '<NOT PROVIDED>'),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Password logged in plaintext via defaultConfig

defaultConfig.password holds the raw value of env.RENDERER_PASSWORD (set at module load, line 171). By logging defaultConfig directly — without wrapping it in sanitizedSettings() — the password is now emitted in plaintext in the 'Default settings' log entry every time logSanitizedConfig() is called.

The previous code explicitly masked it:

'Default settings at module load (env-backed values may lag current runtime)': sanitizedSettings(
  defaultConfig,
  '<NOT PROVIDED AT MODULE LOAD>',
),

Apply the same sanitizedSettings wrapper here:

Suggested change
'Default settings': defaultConfig,
'ENV values used for settings (use "RENDERER_" prefix)': envValuesUsed(),
'Customized values for settings from config object (overrides ENV)': sanitizedSettings(userConfig),
'Final renderer settings': sanitizedSettings(config, '<NOT PROVIDED>'),
});
'Default settings': sanitizedSettings(defaultConfig, '<NOT PROVIDED>'),

Comment on lines 109 to +113
await moveUploadedAsset(providedNewBundle.bundle, bundleFilePathPerTimestamp);
if (assetsToCopy) {
await copyUploadedAssets(assetsToCopy, bundleDirectory);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Assets no longer copied when bundle already exists (race condition path)

Moving copyUploadedAssets inside the try block means it is skipped when moveUploadedAsset throws because the destination file already exists (the race-condition catch path). The removed code deliberately placed this call after the catch and included the comment:

Always copy assets to the bundle directory — even if the bundle was already present (e.g., from a prior upload or another worker). copyUploadedAssets uses overwrite:true, so this is idempotent.

With this change, if two workers race to upload the same bundle and Worker B's move fails because Worker A already wrote the file, Worker B's assets are never copied to the bundle directory.

Consider restoring the unconditional copy inside the catch path:

    } catch (error) {
      const fileExists = await fileExistsAsync(bundleFilePathPerTimestamp);
      if (!fileExists) {
        return errorResponseResult(msg);
      }
      log.info('File exists when trying to overwrite bundle. Assuming bundle written by other thread');
      if (assetsToCopy) {
        await copyUploadedAssets(assetsToCopy, bundleDirectory);
      }
    }

Comment on lines +453 to +462
first_chunk_var.value = e
# Variable accepted the error — this is a pre-first-chunk failure (e.g., shell error).
# The caller will detect the Exception value and raise it synchronously,
# BEFORE the response is committed, enabling a proper HTTP redirect.
# Do NOT re-raise here: the caller owns the error now.
rescue StandardError
# Variable was already resolved — the first chunk was returned successfully.
# This is a post-first-chunk error. Re-raise so barrier.wait propagates it
# (the response is already committed at that point, so only JS redirect is possible).
raise e
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Catch clause swallows Async::Variable assignment exception type

When the variable is already resolved and first_chunk_var.value = e raises, the rescue StandardError catches it and re-raises e. This works as long as Async::Variable raises a StandardError subclass on a second assignment. If a future version of the async gem raises a non-StandardError, the double-assignment exception would propagate unhandled.

A more explicit approach using a settled flag avoids coupling to the gem's exception type:

settled = false
@async_barrier.async do
  stream = yield
  settled = true
  fully_consumed = process_stream_chunks(stream, first_chunk_var, all_chunks)
  on_complete&.call(all_chunks) if fully_consumed
rescue StandardError => e
  if settled
    raise e
  else
    first_chunk_var.value = e
  end
end

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7ca7a5ad4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

defaultConfig,
'<NOT PROVIDED AT MODULE LOAD>',
),
'Default settings': defaultConfig,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mask renderer password before logging default settings

logSanitizedConfig now logs defaultConfig directly, but defaultConfig.password is populated from RENDERER_PASSWORD. In clustered mode (master.ts calls this on startup), this writes the raw renderer shared secret to logs whenever password auth is configured, which can leak credentials to log files and external log aggregation systems. The default settings payload should be sanitized/redacted before logging.

Useful? React with 👍 / 👎.

Comment on lines +110 to +112
if (assetsToCopy) {
await copyUploadedAssets(assetsToCopy, bundleDirectory);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Copy assets even when bundle file already exists

copyUploadedAssets is now inside the moveUploadedAsset success path only. If another worker/request already wrote the same bundle, moveUploadedAsset can throw, the catch path accepts that race (File exists...), and the function returns without copying this request's assets. In concurrent upload/render scenarios, that can leave bundle directories with missing or stale auxiliary assets when the winning writer did not provide the same asset set/version.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb (1)

149-155: Use precise wording for this example description.

Line 149 says “blank,” but the assertion on Line 154 specifically expects nil. Consider renaming to “is nil if not provided in the URL” to avoid implying empty-string acceptance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb` around
lines 149 - 155, Update the example description string to accurately reflect the
expectation: change the spec description that currently reads "is blank if not
provided in the URL" to "is nil if not provided in the URL" so it matches the
assertion on ReactOnRailsPro.configuration.renderer_password (and the example
using ReactOnRailsPro.configure / config.renderer_url).
react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb (1)

414-415: Consider retaining timeout protection for test reliability.

Removing the with_timeout(5) wrapper means tests could hang indefinitely if a regression causes the barrier to never complete. While the current implementation should be correct, a timeout provides a safety net that converts hangs into clear failures.

That said, this is a low-risk change given the controlled test environment with finite mocked chunks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb`
around lines 414 - 415, Reintroduce the timeout guard around the async barrier
wait and queue close to avoid test hangs: wrap the calls to `@async_barrier.wait`
and `@main_output_queue.close` in with_timeout(5) so the test fails fast on
regressions; locate the test block that currently calls `@async_barrier.wait` and
`@main_output_queue.close` and restore the with_timeout(5) wrapper (using the
existing with_timeout helper) to enforce a 5-second timeout.
react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb (1)

628-656: Consider adding test coverage for pre-first-chunk error propagation.

Based on the AI summary, a spec that verified pre-first-chunk errors (e.g., shell errors from render_to_string) don't result in stream writes was removed. The remaining exception handling tests (lines 629-655) only cover post-first-chunk scenarios (writer exceptions during streaming).

The Async::Variable-based error propagation logic in consumer_stream_async (lines 452-463 in the helper) is critical for ensuring HTTP redirects work for shell errors. Consider adding a test that:

  1. Stubs render_to_string to raise before any chunks are produced
  2. Verifies the exception propagates synchronously
  3. Confirms stream.write is never called

Would you like me to help draft a test case that covers pre-first-chunk error propagation?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb` around lines 628 -
656, Add a new spec that ensures pre-first-chunk errors from the rendering phase
propagate synchronously and never trigger any stream writes: use
setup_stream_test to get queues, controller, and stream; stub the renderer
(e.g., render_to_string or the helper that produces the first chunk used by
consumer_stream_async) to raise an exception before any chunks are enqueued;
assert that running the stream raises that error immediately (not deadlocked)
and verify stream.write is never called (use allow(stream).to receive(:write)
and expect not_to have_received(:write)); this exercises the
Async::Variable-based error propagation in consumer_stream_async so HTTP
redirects/shell errors are handled prior to the first chunk.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react-on-rails-pro-node-renderer/package.json`:
- Line 32: package.json now lists "fastify": "^5.8.1" but the pnpm-lock.yaml is
out of sync (it still references ^5.8.3); run pnpm install (without
--frozen-lockfile) to regenerate pnpm-lock.yaml and commit the updated lockfile
so CI matches package.json, or if you intended to pin a specific release change
the dependency in package.json to the exact version instead of "^5.8.1".

---

Nitpick comments:
In
`@react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb`:
- Around line 414-415: Reintroduce the timeout guard around the async barrier
wait and queue close to avoid test hangs: wrap the calls to `@async_barrier.wait`
and `@main_output_queue.close` in with_timeout(5) so the test fails fast on
regressions; locate the test block that currently calls `@async_barrier.wait` and
`@main_output_queue.close` and restore the with_timeout(5) wrapper (using the
existing with_timeout helper) to enforce a 5-second timeout.

In `@react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb`:
- Around line 149-155: Update the example description string to accurately
reflect the expectation: change the spec description that currently reads "is
blank if not provided in the URL" to "is nil if not provided in the URL" so it
matches the assertion on ReactOnRailsPro.configuration.renderer_password (and
the example using ReactOnRailsPro.configure / config.renderer_url).

In `@react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb`:
- Around line 628-656: Add a new spec that ensures pre-first-chunk errors from
the rendering phase propagate synchronously and never trigger any stream writes:
use setup_stream_test to get queues, controller, and stream; stub the renderer
(e.g., render_to_string or the helper that produces the first chunk used by
consumer_stream_async) to raise an exception before any chunks are enqueued;
assert that running the stream raises that error immediately (not deadlocked)
and verify stream.write is never called (use allow(stream).to receive(:write)
and expect not_to have_received(:write)); this exercises the
Async::Variable-based error propagation in consumer_stream_async so HTTP
redirects/shell errors are handled prior to the first chunk.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4e6b2f7e-b2c7-438c-8383-78dfdbbc8545

📥 Commits

Reviewing files that changed from the base of the PR and between c5288af and e7ca7a5.

📒 Files selected for processing (19)
  • packages/react-on-rails-pro-node-renderer/package.json
  • packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts
  • packages/react-on-rails-pro-node-renderer/src/worker.ts
  • packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts
  • packages/react-on-rails-pro-node-renderer/tests/configBuilder.test.ts
  • packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts
  • packages/react-on-rails-pro-node-renderer/tests/worker.test.ts
  • packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
  • packages/react-on-rails-pro/src/tanstack-router/serverRender.ts
  • packages/react-on-rails-pro/src/tanstack-router/types.ts
  • packages/react-on-rails-pro/tests/tanstackRouter.test.ts
  • react_on_rails/lib/react_on_rails/helper.rb
  • react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
  • react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
  • react_on_rails_pro/lib/react_on_rails_pro/request.rb
  • react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb
  • react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb
  • react_on_rails_pro/spec/react_on_rails_pro/stream_integration_spec.rb
  • react_on_rails_pro/spec/react_on_rails_pro/stream_spec.rb
💤 Files with no reviewable changes (1)
  • react_on_rails_pro/lib/react_on_rails_pro/configuration.rb

"@fastify/formbody": "^7.4.0 || ^8.0.2",
"@fastify/multipart": "^8.3.1 || ^9.0.3",
"fastify": "^5.8.3",
"fastify": "^5.8.1",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Lockfile is out of sync — regenerate with pnpm install.

The pipeline failures indicate pnpm-lock.yaml still specifies fastify: ^5.8.3 while package.json now declares ^5.8.1. Run pnpm install (without --frozen-lockfile) and commit the updated lockfile to fix CI.

Also, note that ^5.8.1 is a broader range that includes 5.8.3, so this change doesn't prevent installing newer patch versions — it just lowers the floor. If the intent was to pin a specific version, consider using an exact version instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro-node-renderer/package.json` at line 32,
package.json now lists "fastify": "^5.8.1" but the pnpm-lock.yaml is out of sync
(it still references ^5.8.3); run pnpm install (without --frozen-lockfile) to
regenerate pnpm-lock.yaml and commit the updated lockfile so CI matches
package.json, or if you intended to pin a specific release change the dependency
in package.json to the exact version instead of "^5.8.1".

@justin808
Copy link
Copy Markdown
Member Author

start over

@justin808 justin808 closed this Mar 31, 2026
@justin808 justin808 deleted the split/pro-runtime-renderer branch March 31, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant