Skip to content

Commit 758a2c7

Browse files
ihabadhamclaude
andcommitted
Switch SSR to the Pro Node renderer on webpack
Second of three stacked sub-PRs in the Pro RSC migration. Routes all server rendering through the Pro Node renderer (port 3800) instead of ExecJS, and flips the asset bundler from rspack to webpack — scoped reversal of #702, needed because rspack 2.0.0-beta.7's webpack compatibility layer doesn't cover the APIs upstream RSCWebpackPlugin requires. We flip back to rspack once shakacode/react_on_rails_rsc#29 ships a native rspack RSC plugin. The bundler flip and NodeRenderer wiring ship atomically: the server bundle produced by the Pro webpack transforms (target: 'node' + libraryTarget: 'commonjs2') is not evaluable by ExecJS, so the initializer pointing server_renderer at the NodeRenderer must land at the same time. Key changes: - config/shakapacker.yml: assets_bundler: rspack → webpack - config/webpack/bundlerUtils.js: return @rspack/core or webpack based on the shakapacker setting (was rspack-only and threw otherwise); spec updated in parallel - config/webpack/serverWebpackConfig.js: Pro transforms per the :pro generator's update_webpack_config_for_pro and the marketplace/dummy references — target: 'node' + node: false, libraryTarget: 'commonjs2', extractLoader helper, babelLoader.options.caller = { ssr: true }, destructured module.exports so Sub-PR 3's rscWebpackConfig.js can derive from serverWebpackConfig(true). RSCWebpackPlugin({ isServer: true }) when !rscBundle emits the server manifest; inert until Sub-PR 3 activates RSC support - config/initializers/react_on_rails_pro.rb: NodeRenderer-only config (no RSC fields — those move in Sub-PR 3) - renderer/node-renderer.js: launcher with strict integer env parsing, CI worker cap, and additionalContext: { URL, AbortController } so react-router-dom's NavLink.encodeLocation does not throw "ReferenceError: URL is not defined" at SSR time - Procfile.dev: renderer: NODE_ENV=development node renderer/node-renderer.js - package.json: react-on-rails-pro-node-renderer 16.6.0 and react-on-rails-rsc ^19.0.4 (Pro peer dep; required for the RSCWebpackPlugin import) - .gitignore: /renderer/.node-renderer-bundles/ - .env.example: REACT_ON_RAILS_PRO_LICENSE, RENDERER_PASSWORD, and REACT_RENDERER_URL with dev vs prod guidance - .github/workflows/rspec_test.yml: start the Node renderer before rspec with PID liveness and port-ready checks plus log capture on failure Verified locally: webpack build compiles cleanly. `bin/rails s` on 3000 with `node renderer/node-renderer.js` on 3800 renders GET / at HTTP 200; Rails log shows "Node Renderer responded" and the renderer log emits "[SERVER] RENDERED Footer to dom node with id: ..." — confirming SSR went through the Pro path rather than falling back to ExecJS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5551af9 commit 758a2c7

13 files changed

Lines changed: 717 additions & 120 deletions

File tree

.env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# React on Rails Pro license — required in production.
2+
# Issue a license at https://pro.reactonrails.com/ and paste the JWT token below.
3+
# Not required in development or test (the Pro engine logs an info notice and
4+
# continues). Don't commit the real token — copy this file to `.env`, which is
5+
# gitignored.
6+
REACT_ON_RAILS_PRO_LICENSE=
7+
8+
# Shared secret between the Rails app and the Node renderer. Must match
9+
# config.renderer_password in config/initializers/react_on_rails_pro.rb.
10+
# In production, set this to a strong random value via your secret store.
11+
# In development, both the renderer and the initializer default to
12+
# `local-dev-renderer-password` so `bin/dev` works without any setup.
13+
RENDERER_PASSWORD=
14+
15+
# Node renderer HTTP endpoint. Defaults to http://localhost:3800 in
16+
# development (where Procfile.dev runs `node renderer/node-renderer.js`).
17+
# Override in production to point at the deployed renderer host.
18+
# REACT_RENDERER_URL=http://localhost:3800

.github/workflows/rspec_test.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,27 @@ jobs:
8282
- name: Build shakapacker chunks
8383
run: NODE_ENV=development bundle exec bin/shakapacker
8484

85+
- name: Start Node renderer for SSR
86+
run: |
87+
node renderer/node-renderer.js > /tmp/node-renderer.log 2>&1 &
88+
RENDERER_PID=$!
89+
echo "Waiting for Node renderer (PID $RENDERER_PID) on port 3800..."
90+
for i in $(seq 1 30); do
91+
if ! kill -0 $RENDERER_PID 2>/dev/null; then
92+
echo "Node renderer process exited unexpectedly. Log output:"
93+
cat /tmp/node-renderer.log
94+
exit 1
95+
fi
96+
if nc -z localhost 3800 2>/dev/null; then
97+
echo "Node renderer is ready"
98+
exit 0
99+
fi
100+
sleep 1
101+
done
102+
echo "Node renderer failed to start within 30 seconds. Log output:"
103+
cat /tmp/node-renderer.log
104+
exit 1
105+
85106
- name: Run rspec with xvfb
86107
uses: coactions/setup-xvfb@v1
87108
with:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ client/app/bundles/comments/rescript/**/*.bs.js
5757
# Using React on Rails default directory
5858
/ssr-generated/
5959

60+
# Pro Node renderer bundle cache
61+
/renderer/.node-renderer-bundles/
62+
6063
# Generated React on Rails packs
6164
**/generated/**
6265

Procfile.dev

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ rescript: yarn res:watch
1010
rails: bundle exec thrust bin/rails server -p 3000
1111
# Client Rspack dev server with HMR
1212
wp-client: RAILS_ENV=development NODE_ENV=development bin/shakapacker-dev-server
13-
# Server Rspack watcher for SSR bundle
13+
# Server webpack watcher for SSR bundle
1414
wp-server: SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch
15+
# Pro Node renderer — executes the server bundle for SSR (port 3800)
16+
renderer: NODE_ENV=development node renderer/node-renderer.js

client/__tests__/webpack/bundlerUtils.spec.js

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/* eslint-disable max-classes-per-file */
22
/* eslint-disable global-require */
33
/**
4-
* Unit tests for bundlerUtils.js in Rspack-only mode.
4+
* Unit tests for bundlerUtils.js. The utility returns the active bundler
5+
* module based on shakapacker.yml's `assets_bundler` setting; it supports
6+
* both webpack and rspack.
57
*/
68

79
jest.mock('@rspack/core', () => ({
@@ -10,12 +12,18 @@ jest.mock('@rspack/core', () => ({
1012
optimize: { LimitChunkCountPlugin: class MockRspackLimitChunkCount {} },
1113
}));
1214

15+
jest.mock('webpack', () => ({
16+
ProvidePlugin: class MockWebpackProvidePlugin {},
17+
DefinePlugin: class MockWebpackDefinePlugin {},
18+
optimize: { LimitChunkCountPlugin: class MockWebpackLimitChunkCount {} },
19+
}));
20+
1321
describe('bundlerUtils', () => {
1422
let mockConfig;
1523

1624
beforeEach(() => {
1725
jest.resetModules();
18-
mockConfig = { assets_bundler: 'rspack' };
26+
mockConfig = { assets_bundler: 'webpack' };
1927
});
2028

2129
afterEach(() => {
@@ -35,32 +43,30 @@ describe('bundlerUtils', () => {
3543
expect(bundler.CssExtractRspackPlugin.name).toBe('MockCssExtractRspackPlugin');
3644
});
3745

38-
it('throws when assets_bundler is webpack', () => {
46+
it('returns webpack when assets_bundler is webpack', () => {
3947
mockConfig.assets_bundler = 'webpack';
4048
jest.doMock('shakapacker', () => ({ config: mockConfig }));
4149
const utils = require('../../../config/webpack/bundlerUtils');
4250

43-
expect(() => utils.getBundler()).toThrow('configured for Rspack only');
51+
const bundler = utils.getBundler();
52+
53+
expect(bundler).toBeDefined();
54+
expect(bundler.DefinePlugin).toBeDefined();
55+
expect(bundler.DefinePlugin.name).toBe('MockWebpackDefinePlugin');
4456
});
4557

46-
it('throws when assets_bundler is undefined', () => {
58+
it('returns webpack when assets_bundler is undefined', () => {
4759
mockConfig.assets_bundler = undefined;
4860
jest.doMock('shakapacker', () => ({ config: mockConfig }));
4961
const utils = require('../../../config/webpack/bundlerUtils');
5062

51-
expect(() => utils.getBundler()).toThrow('configured for Rspack only');
52-
});
53-
54-
it('throws when assets_bundler is invalid', () => {
55-
mockConfig.assets_bundler = 'invalid-bundler';
56-
jest.doMock('shakapacker', () => ({ config: mockConfig }));
57-
const utils = require('../../../config/webpack/bundlerUtils');
63+
const bundler = utils.getBundler();
5864

59-
expect(() => utils.getBundler()).toThrow('configured for Rspack only');
65+
expect(bundler.DefinePlugin).toBeDefined();
6066
});
6167

6268
it('returns cached bundler on subsequent calls', () => {
63-
mockConfig.assets_bundler = 'rspack';
69+
mockConfig.assets_bundler = 'webpack';
6470
jest.doMock('shakapacker', () => ({ config: mockConfig }));
6571
const utils = require('../../../config/webpack/bundlerUtils');
6672

@@ -106,7 +112,7 @@ describe('bundlerUtils', () => {
106112
jest.doMock('shakapacker', () => ({ config: mockConfig }));
107113
const utils = require('../../../config/webpack/bundlerUtils');
108114

109-
expect(() => utils.getCssExtractPlugin()).toThrow('configured for Rspack only');
115+
expect(() => utils.getCssExtractPlugin()).toThrow('only available when assets_bundler is rspack');
110116
});
111117
});
112118
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
# See https://reactonrails.com/docs/configuration/configuration-pro
4+
# Production license: set REACT_ON_RAILS_PRO_LICENSE to the JWT token from
5+
# https://pro.reactonrails.com/. Development and test environments don't
6+
# require a license — the Pro engine logs an info-level notice only.
7+
ReactOnRailsPro.configure do |config|
8+
# Route all server rendering through the Pro Node renderer (port 3800).
9+
# Falling back to ExecJS is disabled so any renderer issue surfaces loudly
10+
# instead of silently degrading.
11+
config.server_renderer = "NodeRenderer"
12+
config.renderer_use_fallback_exec_js = false
13+
14+
# Renderer HTTP endpoint. The Node renderer listens on localhost:3800 in
15+
# development; override REACT_RENDERER_URL in production to point at the
16+
# deployed renderer host.
17+
config.renderer_url = ENV.fetch("REACT_RENDERER_URL", "http://localhost:3800")
18+
19+
# Shared secret for renderer authentication. Must match renderer/node-renderer.js.
20+
# In production, set RENDERER_PASSWORD to a strong value via your secret store.
21+
# The shared dev default keeps the launcher and initializer in sync locally
22+
# so `bin/dev` just works.
23+
config.renderer_password = ENV.fetch("RENDERER_PASSWORD", "local-dev-renderer-password")
24+
end

config/shakapacker.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ default: &default
99
webpack_compile_output: true
1010
nested_entries: true
1111
javascript_transpiler: swc
12-
assets_bundler: rspack
12+
# Tactical: running on webpack during the Pro RSC migration. The project will
13+
# flip back to rspack once react_on_rails_rsc ships a native rspack RSC plugin
14+
# (tracked at shakacode/react_on_rails_rsc#29). The rspack 2.0.0-beta.7 webpack
15+
# compatibility layer doesn't cover the APIs the upstream webpack RSC plugin
16+
# requires (contextModuleFactory.resolveDependencies, ModuleDependency), so the
17+
# upstream RSCWebpackPlugin can't run on rspack yet.
18+
assets_bundler: webpack
1319

1420
# Additional paths webpack should lookup modules
1521
# ['app/assets', 'engine/foo/app/assets']

config/webpack/bundlerUtils.js

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,24 @@
11
/**
2-
* Bundler utilities for Rspack-only configuration.
3-
*
4-
* This repository standardizes on Rspack with Shakapacker.
2+
* Bundler utilities. Returns the active bundler module based on shakapacker.yml's
3+
* `assets_bundler` setting. Supports both webpack and rspack so this project can
4+
* switch between them without touching every config file.
55
*/
66

77
const { config } = require('shakapacker');
88

9-
// Cache for bundler module
109
let _cachedBundler = null;
1110

12-
const ensureRspack = () => {
13-
if (config.assets_bundler !== 'rspack') {
14-
throw new Error(
15-
`Invalid assets_bundler: "${config.assets_bundler}". ` +
16-
'This project is configured for Rspack only. ' +
17-
'Set assets_bundler: rspack in config/shakapacker.yml',
18-
);
19-
}
20-
};
21-
2211
/**
23-
* Gets the Rspack module for the current build.
12+
* Gets the bundler module for the current build.
2413
*
25-
* @returns {Object} @rspack/core module
26-
* @throws {Error} If assets_bundler is not 'rspack'
14+
* @returns {Object} webpack or @rspack/core module
2715
*/
2816
const getBundler = () => {
29-
ensureRspack();
30-
3117
if (_cachedBundler) {
3218
return _cachedBundler;
3319
}
3420

35-
_cachedBundler = require('@rspack/core');
21+
_cachedBundler = config.assets_bundler === 'rspack' ? require('@rspack/core') : require('webpack');
3622

3723
return _cachedBundler;
3824
};
@@ -45,11 +31,21 @@ const getBundler = () => {
4531
const isRspack = () => config.assets_bundler === 'rspack';
4632

4733
/**
48-
* Gets the CSS extraction plugin for Rspack.
34+
* Gets the CSS extraction plugin. Only meaningful on rspack — webpack projects
35+
* use mini-css-extract-plugin directly via shakapacker's generated config.
4936
*
5037
* @returns {Object} CssExtractRspackPlugin
38+
* @throws {Error} If assets_bundler is not rspack
5139
*/
52-
const getCssExtractPlugin = () => getBundler().CssExtractRspackPlugin;
40+
const getCssExtractPlugin = () => {
41+
if (!isRspack()) {
42+
throw new Error(
43+
'getCssExtractPlugin() is only available when assets_bundler is rspack. ' +
44+
"On webpack, rely on shakapacker's generated MiniCssExtractPlugin configuration.",
45+
);
46+
}
47+
return getBundler().CssExtractRspackPlugin;
48+
};
5349

5450
module.exports = {
5551
getBundler,

0 commit comments

Comments
 (0)