Skip to content

Commit 32399fb

Browse files
Initial RSC Implementation
Signed-off-by: Khaled Emara <[email protected]>
1 parent 7dd8750 commit 32399fb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+9038
-427
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module ReactOnRailsPro
4+
# TODO: Make mountable through Engine
5+
class RscController < ApplicationController
6+
include ActionController::Live
7+
include ReactOnRails::Helper
8+
9+
def render_rsc
10+
render_options = ReactOnRails::ReactComponent::RenderOptions.new(
11+
react_component_name: params[:componentName],
12+
options: {
13+
trace: true,
14+
replay_console: true,
15+
raise_on_prerender_error: true
16+
}
17+
)
18+
19+
# TODO: RailsContext
20+
js_code = ReactOnRailsPro::ServerRenderingJsCode.render_rsc(
21+
props_string(params[:props] || {}).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'),
22+
initialize_redux_stores,
23+
params[:componentName],
24+
render_options
25+
)
26+
27+
ReactOnRailsPro::ServerRenderingPool::ProRendering.reset_pool_if_server_bundle_was_modified
28+
ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool.eval_js(
29+
js_code,
30+
render_options,
31+
false,
32+
response
33+
)
34+
ensure
35+
response.stream.close
36+
end
37+
end
38+
end

config/routes.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
Rails.application.routes.draw do
4+
get "rsc/:componentName" => "react_on_rails_pro/rsc#render_rsc", as: :rsc
5+
end

lib/react_on_rails_pro/request.rb

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ def reset_connection
1212
@connection = create_connection
1313
end
1414

15-
def render_code(path, js_code, send_bundle)
15+
def render_code(path, js_code, send_bundle, live_response = nil)
1616
Rails.logger.info { "[ReactOnRailsPro] Perform rendering request #{path}" }
17-
perform_request(path, form_with_code(js_code, send_bundle))
17+
perform_request(path, form_with_code(js_code, send_bundle), live_response)
1818
end
1919

2020
def upload_assets
@@ -34,43 +34,84 @@ def connection
3434
@connection ||= create_connection
3535
end
3636

37-
def perform_request(path, form)
38-
available_retries = ReactOnRailsPro.configuration.renderer_request_retry_limit
39-
retry_request = true
40-
while retry_request
41-
begin
42-
response = connection.request(Net::HTTP::Post::Multipart.new(path, form))
43-
retry_request = false
44-
rescue Timeout::Error => e
45-
# Testing timeout catching:
46-
# https://github.com/shakacode/react_on_rails_pro/pull/136#issue-463421204
47-
if available_retries.zero?
48-
raise ReactOnRailsPro::Error, "Time out error when getting the response on: #{path}.\n" \
37+
# TODO: Fix Metrics/AbcSize
38+
# rubocop:disable Metrics/AbcSize
39+
def perform_request(path, form, live_response = nil)
40+
if live_response
41+
date = Time.now.httpdate
42+
43+
live_response.headers["Content-Type"] = "text/x-component"
44+
live_response.headers["Date"] = date
45+
live_response.headers["Connection"] = "keep-alive"
46+
live_response.headers["Keep-Alive"] = "timeout=5"
47+
live_response.headers["Last-Modified"] = date
48+
end
49+
50+
response_code = nil
51+
response_body = ""
52+
53+
cache_key = Digest::MD5.hexdigest(form["renderingRequest"])
54+
cached_response = Rails.cache.read(cache_key)
55+
56+
if cached_response
57+
response_code = "200"
58+
response_body = cached_response
59+
live_response.status = 200
60+
live_response.stream.write(response_body)
61+
else
62+
available_retries = ReactOnRailsPro.configuration.renderer_request_retry_limit
63+
retry_request = true
64+
65+
while retry_request
66+
begin
67+
connection.request(Net::HTTP::Post::Multipart.new(path, form)) do |res|
68+
response_code = res.code
69+
if response_code == "200"
70+
if live_response
71+
live_response.status = res.code
72+
end
73+
res.read_body do |chunk|
74+
response_body += chunk
75+
if live_response
76+
live_response.stream.write(chunk)
77+
end
78+
end
79+
Rails.cache.write(cache_key, response_body)
80+
end
81+
end
82+
retry_request = false
83+
rescue Timeout::Error => e
84+
# Testing timeout catching:
85+
# https://github.com/shakacode/react_on_rails_pro/pull/136#issue-463421204
86+
if available_retries.zero?
87+
raise ReactOnRailsPro::Error, "Time out error when getting the response on: #{path}.\n" \
88+
"Original error:\n#{e}\n#{e.backtrace}"
89+
end
90+
Rails.logger.info do
91+
"[ReactOnRailsPro] Timed out trying to connect to the Node Renderer. " \
92+
"Retrying #{available_retries} more times..."
93+
end
94+
available_retries -= 1
95+
next
96+
rescue StandardError => e
97+
raise ReactOnRailsPro::Error, "Can't connect to NodeRenderer renderer: #{path}.\n" \
4998
"Original error:\n#{e}\n#{e.backtrace}"
5099
end
51-
Rails.logger.info do
52-
"[ReactOnRailsPro] Timed out trying to connect to the Node Renderer. " \
53-
"Retrying #{available_retries} more times..."
54-
end
55-
available_retries -= 1
56-
next
57-
rescue StandardError => e
58-
raise ReactOnRailsPro::Error, "Can't connect to NodeRenderer renderer: #{path}.\n" \
59-
"Original error:\n#{e}\n#{e.backtrace}"
60100
end
61101
end
62102

63103
Rails.logger.info { "[ReactOnRailsPro] Node Renderer responded" }
64104

65-
case response.code
105+
case response_code
66106
when "412"
67107
# 412 is a protocol error, meaning the server and renderer are running incompatible versions
68108
# of React on Rails.
69-
raise ReactOnRailsPro::Error, response.body
109+
raise ReactOnRailsPro::Error, response_body
70110
else
71-
response
111+
OpenStruct.new(code: response_code, body: response_body)
72112
end
73113
end
114+
# rubocop:enable Metrics/AbcSize
74115

75116
def form_with_code(js_code, send_bundle)
76117
form = common_form_data

lib/react_on_rails_pro/server_rendering_js_code.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend
2626
})()
2727
JS
2828
end
29+
30+
# TODO: async?
31+
def render_rsc(props_string, redux_stores, react_component_name, render_options)
32+
<<-JS
33+
(function() {
34+
#{ssr_pre_hook_js}
35+
#{redux_stores}
36+
var props = #{props_string};
37+
return ReactOnRailsPro.renderReactServerComponent({
38+
name: '#{react_component_name}',
39+
domNodeId: '#{render_options.dom_id}',
40+
props: props,
41+
trace: #{render_options.trace},
42+
throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors},
43+
renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises},
44+
expressRes: expressRes
45+
});
46+
})()
47+
JS
48+
end
2949
end
3050
end
3151
end

lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,21 @@ def exec_server_render_js(js_code, render_options)
4141
.exec_server_render_js(js_code, render_options, self)
4242
end
4343

44-
def eval_js(js_code, render_options, send_bundle: false)
44+
def eval_js(js_code, render_options, send_bundle = false, live_response = nil)
4545
# In case this method is called with simple, raw JS, not depending on the bundle, next line
4646
# is needed.
4747
@bundle_hash ||= ReactOnRailsPro::Utils.bundle_hash
4848

4949
path = "/bundles/#{@bundle_hash}"
5050

51-
response = ReactOnRailsPro::Request.render_code(path, js_code, send_bundle)
51+
response = ReactOnRailsPro::Request.render_code(path, js_code, send_bundle, live_response)
5252

5353
case response.code
5454
when "200"
5555
response.body
5656
when "410"
5757
# 410 is a special value meaning send the updated bundle with the next request.
58-
eval_js(js_code, render_options, send_bundle: true)
58+
eval_js(js_code, render_options, true, live_response)
5959
when "400"
6060
raise ReactOnRailsPro::Error,
6161
"Renderer unhandled error at the VM level: #{response.code}:\n#{response.body}"

packages/node-renderer/src/worker.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,12 @@ module.exports = function run(config) {
131131
bundleTimestamp,
132132
providedNewBundle,
133133
assetsToCopy,
134+
res,
134135
});
135-
setResponse(result, res);
136+
// Check if response is already sent
137+
if (!res.headersSent) {
138+
setResponse(result, res);
139+
}
136140
} catch (err) {
137141
const exceptionMessage = formatExceptionMessage(
138142
renderingRequest,
@@ -149,7 +153,7 @@ module.exports = function run(config) {
149153
}
150154
},
151155
'handleRenderRequest',
152-
'SSR Request',
156+
'Server Render Request',
153157
);
154158
} catch (theErr) {
155159
const exceptionMessage = formatExceptionMessage(renderingRequest, theErr);

packages/node-renderer/src/worker/handleRenderRequest.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ const { buildVM, getVmBundleFilePath, runInVM } = require('./vm');
2727
* @param renderingRequest
2828
* @returns {Promise<*>}
2929
*/
30-
async function prepareResult(renderingRequest) {
30+
async function prepareResult(renderingRequest, res) {
3131
try {
32-
const result = await runInVM(renderingRequest, cluster);
32+
const result = await runInVM(renderingRequest, cluster, res);
3333

3434
let exceptionMessage = null;
3535
if (!result) {
@@ -72,6 +72,7 @@ async function handleNewBundleProvided(
7272
providedNewBundle,
7373
renderingRequest,
7474
assetsToCopy,
75+
res,
7576
) {
7677
log.info('Worker received new bundle: %s', bundleFilePathPerTimestamp);
7778

@@ -122,7 +123,7 @@ to ${bundleFilePathPerTimestamp})`,
122123
// file must be fully written
123124
log.info('buildVM, bundleFilePathPerTimestamp', bundleFilePathPerTimestamp);
124125
await buildVM(bundleFilePathPerTimestamp);
125-
return prepareResult(renderingRequest);
126+
return prepareResult(renderingRequest, res);
126127
} catch (error) {
127128
const msg = formatExceptionMessage(
128129
renderingRequest,
@@ -158,13 +159,14 @@ module.exports = async function handleRenderRequest({
158159
bundleTimestamp,
159160
providedNewBundle,
160161
assetsToCopy,
162+
res,
161163
}) {
162164
try {
163165
const bundleFilePathPerTimestamp = getRequestBundleFilePath(bundleTimestamp);
164166

165167
// If current vm is correct and ready
166168
if (getVmBundleFilePath() === bundleFilePathPerTimestamp) {
167-
return prepareResult(renderingRequest);
169+
return prepareResult(renderingRequest, res);
168170
}
169171

170172
// If gem has posted updated bundle:
@@ -174,6 +176,7 @@ module.exports = async function handleRenderRequest({
174176
providedNewBundle,
175177
renderingRequest,
176178
assetsToCopy,
179+
res,
177180
);
178181
}
179182

@@ -201,7 +204,7 @@ module.exports = async function handleRenderRequest({
201204
// have written it or it was saved during deployment.
202205
await buildVM(bundleFilePathPerTimestamp);
203206

204-
return prepareResult(renderingRequest);
207+
return prepareResult(renderingRequest, res);
205208
} catch (error) {
206209
const msg = formatExceptionMessage(
207210
renderingRequest,

packages/node-renderer/src/worker/vm.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ exports.buildVM = async function buildVM(filePath) {
6767
const { supportModules, includeTimerPolyfills } = getConfig();
6868
vmBundleFilePath = undefined;
6969
if (supportModules) {
70-
context = vm.createContext({ Buffer, process, setTimeout, setInterval, clearTimeout, clearInterval });
70+
context = vm.createContext({ Buffer, process, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate });
7171
} else {
7272
context = vm.createContext();
7373
}
@@ -111,8 +111,10 @@ exports.buildVM = async function buildVM(filePath) {
111111
// Define timer polyfills:
112112
vm.runInContext(`function setInterval() {}`, context);
113113
vm.runInContext(`function setTimeout() {}`, context);
114+
vm.runInContext(`function setImmediate() {}`, context);
114115
vm.runInContext(`function clearTimeout() {}`, context);
115116
vm.runInContext(`function clearInterval() {}`, context);
117+
vm.runInContext(`function clearImmediate() {}`, context);
116118
}
117119

118120
// Run bundle code in created context:
@@ -164,7 +166,7 @@ exports.buildVM = async function buildVM(filePath) {
164166
* @param vmCluster
165167
* @returns {{exceptionMessage: string}}
166168
*/
167-
exports.runInVM = async function runInVM(renderingRequest, vmCluster) {
169+
exports.runInVM = async function runInVM(renderingRequest, vmCluster, res) {
168170
const { bundlePath } = getConfig();
169171

170172
try {
@@ -179,6 +181,7 @@ ${smartTrim(renderingRequest)}`);
179181

180182
vm.runInContext('console.history = []', context);
181183

184+
context.expressRes = res;
182185
let result = vm.runInContext(renderingRequest, context);
183186
if (typeof result !== 'string') {
184187
const objectResult = await result;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
node_modules
2+
lib/
3+
webpack.config.js
4+
**/node_modules/**
5+
**/assets/webpack/**
6+
**/public/webpack/**
7+
**/generated/**
8+
**/app/assets/javascripts/application.js
9+
**/cable.js
10+
**/public/packs*/*
11+
bundle/

0 commit comments

Comments
 (0)