react_on_rails_pro 16.2.0.beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/.controlplane/Dockerfile +49 -0
  3. data/.controlplane/controlplane.yml +22 -0
  4. data/.controlplane/gvc.yml +25 -0
  5. data/.controlplane/postgres.yml +33 -0
  6. data/.controlplane/rails.yml +49 -0
  7. data/.controlplane/redis.yml +18 -0
  8. data/.gitignore +77 -0
  9. data/.prettierignore +12 -0
  10. data/.prettierrc +19 -0
  11. data/.rspec +2 -0
  12. data/.rubocop.yml +120 -0
  13. data/.scss-lint.yml +205 -0
  14. data/CHANGELOG.md +570 -0
  15. data/CI_SETUP.md +502 -0
  16. data/CONTRIBUTING.md +376 -0
  17. data/Dockerfile +63 -0
  18. data/Gemfile +8 -0
  19. data/Gemfile.development_dependencies +74 -0
  20. data/Gemfile.loader +32 -0
  21. data/Gemfile.lock +527 -0
  22. data/LICENSE +98 -0
  23. data/LICENSE_SETUP.md +272 -0
  24. data/README.md +577 -0
  25. data/Rakefile +13 -0
  26. data/app/controllers/react_on_rails_pro/rsc_payload_controller.rb +7 -0
  27. data/app/helpers/react_on_rails_pro_helper.rb +360 -0
  28. data/app/views/react_on_rails_pro/rsc_payload.html.erb +1 -0
  29. data/babel.config.js +4 -0
  30. data/docs/bundle-caching.md +205 -0
  31. data/docs/caching.md +234 -0
  32. data/docs/code-splitting-loadable-components.md +313 -0
  33. data/docs/code-splitting.md +349 -0
  34. data/docs/configuration.md +165 -0
  35. data/docs/contributors-info/onboarding-customers.md +6 -0
  36. data/docs/contributors-info/releasing.md +40 -0
  37. data/docs/contributors-info/style.md +33 -0
  38. data/docs/home-pro.md +146 -0
  39. data/docs/installation.md +203 -0
  40. data/docs/js-memory-leaks.md +22 -0
  41. data/docs/node-renderer/basics.md +92 -0
  42. data/docs/node-renderer/debugging.md +38 -0
  43. data/docs/node-renderer/error-reporting-and-tracing.md +160 -0
  44. data/docs/node-renderer/heroku.md +102 -0
  45. data/docs/node-renderer/js-configuration.md +91 -0
  46. data/docs/node-renderer/troubleshooting.md +5 -0
  47. data/docs/profiling-server-side-rendering-code.md +179 -0
  48. data/docs/react-server-components/add-streaming-and-interactivity.md +190 -0
  49. data/docs/react-server-components/create-without-ssr.md +448 -0
  50. data/docs/react-server-components/glossary.md +102 -0
  51. data/docs/react-server-components/how-react-server-components-work.md +243 -0
  52. data/docs/react-server-components/inside-client-components.md +332 -0
  53. data/docs/react-server-components/purpose-and-benefits.md +243 -0
  54. data/docs/react-server-components/rendering-flow.md +86 -0
  55. data/docs/react-server-components/selective-hydration-in-streamed-components.md +75 -0
  56. data/docs/react-server-components/server-side-rendering.md +72 -0
  57. data/docs/react-server-components/tutorial.md +19 -0
  58. data/docs/release-notes/4.0.md +94 -0
  59. data/docs/release-notes/v4-react-server-components.md +66 -0
  60. data/docs/ruby-api.md +11 -0
  61. data/docs/streaming-server-rendering.md +210 -0
  62. data/docs/troubleshooting.md +24 -0
  63. data/docs/updating.md +219 -0
  64. data/eslint.config.mjs +220 -0
  65. data/lib/react_on_rails_pro/assets_precompile.rb +230 -0
  66. data/lib/react_on_rails_pro/cache.rb +88 -0
  67. data/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +38 -0
  68. data/lib/react_on_rails_pro/concerns/stream.rb +103 -0
  69. data/lib/react_on_rails_pro/configuration.rb +228 -0
  70. data/lib/react_on_rails_pro/constants.rb +8 -0
  71. data/lib/react_on_rails_pro/engine.rb +24 -0
  72. data/lib/react_on_rails_pro/error.rb +14 -0
  73. data/lib/react_on_rails_pro/license_public_key.rb +30 -0
  74. data/lib/react_on_rails_pro/license_validator.rb +188 -0
  75. data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +40 -0
  76. data/lib/react_on_rails_pro/rendering_error.rb +5 -0
  77. data/lib/react_on_rails_pro/request.rb +318 -0
  78. data/lib/react_on_rails_pro/routes.rb +13 -0
  79. data/lib/react_on_rails_pro/server_rendering_js_code.rb +102 -0
  80. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +133 -0
  81. data/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb +117 -0
  82. data/lib/react_on_rails_pro/stream_cache.rb +61 -0
  83. data/lib/react_on_rails_pro/stream_request.rb +170 -0
  84. data/lib/react_on_rails_pro/utils.rb +222 -0
  85. data/lib/react_on_rails_pro/v8_log_processor.rb +50 -0
  86. data/lib/react_on_rails_pro/version.rb +6 -0
  87. data/lib/react_on_rails_pro.rb +23 -0
  88. data/package-scripts.yml +109 -0
  89. data/package.json +159 -0
  90. data/rakelib/dummy_apps.rake +22 -0
  91. data/rakelib/lint.rake +32 -0
  92. data/rakelib/public_key_management.rake +155 -0
  93. data/rakelib/rbs.rake +47 -0
  94. data/rakelib/run_rspec.rake +81 -0
  95. data/rakelib/task_helpers.rb +45 -0
  96. data/rakelib/yard.rake +20 -0
  97. data/react_on_rails_pro.gemspec +47 -0
  98. data/readme-gen-docs.md +1 -0
  99. data/script/bootstrap +33 -0
  100. data/script/preinstall.js +31 -0
  101. data/script/setup +23 -0
  102. data/script/test +38 -0
  103. data/sig/react_on_rails_pro/cache.rbs +13 -0
  104. data/sig/react_on_rails_pro/configuration.rbs +100 -0
  105. data/sig/react_on_rails_pro/error.rbs +4 -0
  106. data/sig/react_on_rails_pro/utils.rbs +7 -0
  107. data/sig/react_on_rails_pro.rbs +5 -0
  108. data/yarn.lock +7599 -0
  109. metadata +319 -0
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRailsPro
4
+ module ServerRenderingJsCode
5
+ class << self
6
+ def ssr_pre_hook_js
7
+ ReactOnRailsPro.configuration.ssr_pre_hook_js || ""
8
+ end
9
+
10
+ # Generates the JavaScript function used for React Server Components payload generation
11
+ # Returns the JavaScript code that defines the generateRSCPayload function.
12
+ # It also adds necessary information to the railsContext to generate the RSC payload for any component in the app.
13
+ # @return [String] JavaScript code for RSC payload generation
14
+ def generate_rsc_payload_js_function(render_options)
15
+ return "" unless ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming?
16
+
17
+ if render_options.rsc_payload_streaming?
18
+ # When already on RSC bundle, we prevent further RSC payload generation
19
+ # by throwing an error if generateRSCPayload is called
20
+ return <<-JS
21
+ if (typeof generateRSCPayload !== 'function') {
22
+ globalThis.generateRSCPayload = function generateRSCPayload() {
23
+ throw new Error('The rendering request is already running on the RSC bundle. Please ensure that generateRSCPayload is only called from any React Server Component.')
24
+ }
25
+ }
26
+ JS
27
+ end
28
+
29
+ # To minimize the size of the HTTP request body sent to the node renderer,
30
+ # we reuse the existing rendering request string within the generateRSCPayload function.
31
+ # This approach allows us to simply replace the component name and props,
32
+ # rather than rewriting the entire rendering request.
33
+ # This regex finds the empty function call pattern `()` and replaces it with the component and props
34
+ <<-JS
35
+ railsContext.serverSideRSCPayloadParameters = {
36
+ renderingRequest,
37
+ rscBundleHash: '#{ReactOnRailsPro::Utils.rsc_bundle_hash}',
38
+ }
39
+ if (typeof generateRSCPayload !== 'function') {
40
+ globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
41
+ const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
42
+ const propsString = JSON.stringify(props);
43
+ const newRenderingRequest = renderingRequest.replace(/\\(\\s*\\)\\s*$/, `('${componentName}', ${propsString})`);
44
+ return runOnOtherBundle(rscBundleHash, newRenderingRequest);
45
+ }
46
+ }
47
+ JS
48
+ end
49
+
50
+ # Main rendering function that generates JavaScript code for server-side rendering
51
+ # @param props_string [String] JSON string of props to pass to the React component
52
+ # @param rails_context [String] JSON string of Rails context data
53
+ # @param redux_stores [String] JavaScript code for Redux stores initialization
54
+ # @param react_component_name [String] Name of the React component to render
55
+ # @param render_options [Object] Options that control the rendering behavior
56
+ # @return [String] JavaScript code that will render the React component on the server
57
+ def render(props_string, rails_context, redux_stores, react_component_name, render_options)
58
+ render_function_name =
59
+ if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming?
60
+ # Select appropriate function based on whether the rendering request is running on server or rsc bundle
61
+ # As the same rendering request is used to generate the rsc payload and SSR the component.
62
+ "ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'"
63
+ else
64
+ "'serverRenderReactComponent'"
65
+ end
66
+ rsc_params = if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming?
67
+ config = ReactOnRailsPro.configuration
68
+ react_client_manifest_file = config.react_client_manifest_file
69
+ react_server_client_manifest_file = config.react_server_client_manifest_file
70
+ <<-JS
71
+ railsContext.reactClientManifestFileName = '#{react_client_manifest_file}';
72
+ railsContext.reactServerClientManifestFileName = '#{react_server_client_manifest_file}';
73
+ JS
74
+ else
75
+ ""
76
+ end
77
+
78
+ # This function is called with specific componentName and props when generateRSCPayload is invoked
79
+ # In that case, it replaces the empty () with ('componentName', props) in the rendering request
80
+ <<-JS
81
+ (function(componentName = '#{react_component_name}', props = undefined) {
82
+ var railsContext = #{rails_context};
83
+ #{rsc_params}
84
+ #{generate_rsc_payload_js_function(render_options)}
85
+ #{ssr_pre_hook_js}
86
+ #{redux_stores}
87
+ var usedProps = typeof props === 'undefined' ? #{props_string} : props;
88
+ return ReactOnRails[#{render_function_name}]({
89
+ name: componentName,
90
+ domNodeId: '#{render_options.dom_id}',
91
+ props: usedProps,
92
+ trace: #{render_options.trace},
93
+ railsContext: railsContext,
94
+ throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors},
95
+ renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises},
96
+ });
97
+ })()
98
+ JS
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRailsPro
4
+ module ServerRenderingPool
5
+ # This implementation of the rendering pool uses NodeJS to execute javasript code
6
+ class NodeRenderingPool
7
+ RENDERED_HTML_KEY = "renderedHtml"
8
+
9
+ class << self
10
+ attr_reader :bundle_hash
11
+
12
+ def reset_pool
13
+ ReactOnRailsPro::Request.reset_connection
14
+ end
15
+
16
+ def reset_pool_if_server_bundle_was_modified
17
+ # Resetting the pool for server bundle modifications is accomplished by changing the mtime
18
+ # of the server bundle in the request to the remote rendering server.
19
+ # In non-development mode, we don't need to re-read this value.
20
+ if @server_bundle_hash.blank? || ReactOnRails.configuration.development_mode
21
+ @server_bundle_hash = ReactOnRailsPro::Utils.bundle_hash
22
+ end
23
+
24
+ unless ReactOnRailsPro.configuration.enable_rsc_support &&
25
+ (@rsc_bundle_hash.blank? || ReactOnRails.configuration.development_mode)
26
+ return
27
+ end
28
+
29
+ @rsc_bundle_hash = ReactOnRailsPro::Utils.rsc_bundle_hash
30
+ end
31
+
32
+ def renderer_bundle_file_name
33
+ "#{ReactOnRailsPro::Utils.bundle_hash}.js"
34
+ end
35
+
36
+ def rsc_renderer_bundle_file_name
37
+ "#{ReactOnRailsPro::Utils.rsc_bundle_hash}.js"
38
+ end
39
+
40
+ # js_code: JavaScript expression that returns a string.
41
+ # Returns a Hash:
42
+ # html: string of HTML for direct insertion on the page by evaluating js_code
43
+ # consoleReplayScript: script for replaying console
44
+ # hasErrors: true if server rendering errors
45
+ # Note, js_code does not have to be based on React.
46
+ # js_code MUST RETURN json stringify Object
47
+ # Calling code will probably call 'html_safe' on return value before rendering to the view.
48
+ def exec_server_render_js(js_code, render_options)
49
+ # The secret sauce is passing self as the 3rd param, the js_evaluator
50
+ render_options.set_option(:throw_js_errors, ReactOnRailsPro.configuration.throw_js_errors)
51
+ ReactOnRails::ServerRenderingPool::RubyEmbeddedJavaScript
52
+ .exec_server_render_js(js_code, render_options, self)
53
+ end
54
+
55
+ def eval_streaming_js(js_code, render_options)
56
+ path = prepare_render_path(js_code, render_options)
57
+ ReactOnRailsPro::Request.render_code_as_stream(
58
+ path,
59
+ js_code,
60
+ is_rsc_payload: ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming?
61
+ )
62
+ end
63
+
64
+ def eval_js(js_code, render_options, send_bundle: false)
65
+ path = prepare_render_path(js_code, render_options)
66
+
67
+ response = ReactOnRailsPro::Request.render_code(path, js_code, send_bundle)
68
+
69
+ case response.status
70
+ when 200
71
+ response.body
72
+ when ReactOnRailsPro::STATUS_SEND_BUNDLE
73
+ # To prevent infinite loop
74
+ ReactOnRailsPro::Error.raise_duplicate_bundle_upload_error if send_bundle
75
+
76
+ eval_js(js_code, render_options, send_bundle: true)
77
+ when 400
78
+ raise ReactOnRailsPro::Error,
79
+ "Renderer unhandled error at the VM level: #{response.status}:\n#{response.body}"
80
+ else
81
+ raise ReactOnRailsPro::Error,
82
+ "Unexpected response code from renderer: #{response.status}:\n#{response.body}"
83
+ end
84
+ rescue StandardError => e
85
+ raise e unless ReactOnRailsPro.configuration.renderer_use_fallback_exec_js
86
+
87
+ fallback_exec_js(js_code, render_options, e)
88
+ end
89
+
90
+ def server_bundle_hash
91
+ @server_bundle_hash ||= ReactOnRailsPro::Utils.bundle_hash
92
+ end
93
+
94
+ def rsc_bundle_hash
95
+ @rsc_bundle_hash ||= ReactOnRailsPro::Utils.rsc_bundle_hash
96
+ end
97
+
98
+ def prepare_render_path(js_code, render_options)
99
+ ReactOnRailsPro::ServerRenderingPool::ProRendering
100
+ .set_request_digest_on_render_options(js_code, render_options)
101
+
102
+ rsc_support_enabled = ReactOnRailsPro.configuration.enable_rsc_support
103
+ is_rendering_rsc_payload = rsc_support_enabled && render_options.rsc_payload_streaming?
104
+ bundle_hash = is_rendering_rsc_payload ? rsc_bundle_hash : server_bundle_hash
105
+ # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
106
+ # From the request path
107
+ # path = "/bundles/#{@bundle_hash}/render"
108
+ "/bundles/#{bundle_hash}/render/#{render_options.request_digest}"
109
+ end
110
+
111
+ def fallback_exec_js(js_code, render_options, error)
112
+ Rails.logger.warn do
113
+ "[ReactOnRailsPro] Falling back to ExecJS because of #{error}"
114
+ end
115
+ fallback_renderer = ReactOnRails::ServerRenderingPool::RubyEmbeddedJavaScript
116
+
117
+ # Pool is actually discarded btw requests:
118
+ # 1) not to keep ExecJS in memory once NodeRenderer is available back
119
+ # 2) to avoid issues with server bundle changes
120
+ fallback_renderer.reset_pool
121
+ result = fallback_renderer.eval_js(js_code, render_options)
122
+ fallback_renderer.instance_variable_set(:@js_context_pool, nil)
123
+ result
124
+ end
125
+
126
+ if defined?(ScoutApm)
127
+ include ScoutApm::Tracer
128
+ instrument_method :exec_server_render_js, type: "ReactOnRails", name: "Node React Server Rendering"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRailsPro
4
+ module ServerRenderingPool
5
+ class ProRendering
6
+ RENDERED_HTML_KEY = "renderedHtml"
7
+
8
+ class << self
9
+ def pool
10
+ @pool ||= if ReactOnRailsPro.configuration.node_renderer?
11
+ ::ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
12
+ else
13
+ ::ReactOnRails::ServerRenderingPool::RubyEmbeddedJavaScript
14
+ end
15
+ end
16
+
17
+ delegate :reset_pool_if_server_bundle_was_modified, :reset_pool, to: :pool
18
+
19
+ def exec_server_render_js(js_code, render_options)
20
+ ::ReactOnRailsPro::Utils.with_trace(render_options.react_component_name) do
21
+ # See https://github.com/shakacode/react_on_rails_pro/issues/119 for why
22
+ # the digest is on the render options.
23
+ # TODO: the request digest should be removed unless prerender caching is used
24
+ set_request_digest_on_render_options(js_code, render_options)
25
+
26
+ # Cache non-streaming immediately. For streaming, optionally cache via write-through.
27
+ if cache_enabled_for?(render_options)
28
+ render_with_cache(js_code, render_options)
29
+ else
30
+ render_on_pool(js_code, render_options)
31
+ end
32
+ end
33
+ end
34
+
35
+ # See https://github.com/shakacode/react_on_rails_pro/issues/119 for why
36
+ # the digest is on the render options.
37
+ def set_request_digest_on_render_options(js_code, render_options)
38
+ return unless render_options.request_digest.blank?
39
+
40
+ digest = if render_options.random_dom_id?
41
+ Rails.logger.info do
42
+ "[ReactOnRailsPro] Rendering #{render_options.react_component_name}. " \
43
+ "Suggest setting `id` on react_component or setting react_on_rails.rb initializer " \
44
+ "config.random_dom_id to false for BETTER performance."
45
+ end
46
+ Digest::MD5.hexdigest(without_random_values(js_code))
47
+ else
48
+ Digest::MD5.hexdigest(js_code)
49
+ end
50
+ render_options.request_digest = digest
51
+ end
52
+
53
+ private
54
+
55
+ def cache_enabled_for?(render_options)
56
+ ReactOnRailsPro.configuration.prerender_caching &&
57
+ render_options.internal_option(:skip_prerender_cache).nil?
58
+ end
59
+
60
+ def render_with_cache(js_code, render_options)
61
+ prerender_cache_key = cache_key(js_code, render_options)
62
+ prerender_cache_hit = true
63
+
64
+ result = if render_options.streaming?
65
+ render_streaming_with_cache(prerender_cache_key, js_code, render_options)
66
+ else
67
+ Rails.cache.fetch(prerender_cache_key) do
68
+ prerender_cache_hit = false
69
+ render_on_pool(js_code, render_options)
70
+ end
71
+ end
72
+
73
+ # Pass back the cache key in the results only if the result is a Hash
74
+ if result.is_a?(Hash)
75
+ result[:RORP_CACHE_KEY] = prerender_cache_key
76
+ result[:RORP_CACHE_HIT] = prerender_cache_hit
77
+ end
78
+
79
+ result
80
+ end
81
+
82
+ def render_streaming_with_cache(prerender_cache_key, js_code, render_options)
83
+ # Streaming path: try to serve from cache; otherwise wrap upstream stream
84
+ cached_stream = ReactOnRailsPro::StreamCache.fetch_stream(prerender_cache_key)
85
+ return cached_stream if cached_stream
86
+
87
+ upstream = render_on_pool(js_code, render_options)
88
+ ReactOnRailsPro::StreamCache.wrap_and_cache(
89
+ prerender_cache_key,
90
+ upstream,
91
+ cache_options: render_options.internal_option(:cache_options)
92
+ )
93
+ end
94
+
95
+ def without_random_values(js_code)
96
+ # domNodeId are random to enable multiple instance of the same react component on a page.
97
+ # See https://github.com/shakacode/react_on_rails_pro/issues/44
98
+ js_code.gsub(/domNodeId: '[\w-]*',/, "")
99
+ end
100
+
101
+ def cache_key(js_code, render_options)
102
+ set_request_digest_on_render_options(js_code, render_options)
103
+
104
+ [
105
+ *ReactOnRailsPro::Cache.base_cache_key("ror_pro_rendered_html",
106
+ prerender: render_options.prerender),
107
+ render_options.request_digest
108
+ ]
109
+ end
110
+
111
+ def render_on_pool(js_code, render_options)
112
+ pool.exec_server_render_js(js_code, render_options)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRailsPro
4
+ class StreamCache
5
+ class << self
6
+ # Returns a stream-like object that responds to `each_chunk` and yields cached chunks
7
+ # or nil if not present in cache.
8
+ def fetch_stream(cache_key)
9
+ cached_chunks = Rails.cache.read(cache_key)
10
+ return nil unless cached_chunks.is_a?(Array)
11
+
12
+ build_stream_from_chunks(cached_chunks)
13
+ end
14
+
15
+ # Wraps an upstream stream (responds to `each_chunk`), yields chunks downstream while
16
+ # buffering them, and writes the chunks array to Rails.cache on successful completion.
17
+ # Returns a stream-like object that responds to `each_chunk`.
18
+ def wrap_and_cache(cache_key, upstream_stream, cache_options: nil)
19
+ component = CachingComponent.new(upstream_stream, cache_key, cache_options)
20
+ ReactOnRailsPro::StreamDecorator.new(component)
21
+ end
22
+
23
+ # Builds a stream-like object from an array of chunks.
24
+ def build_stream_from_chunks(chunks)
25
+ component = CachedChunksComponent.new(chunks)
26
+ ReactOnRailsPro::StreamDecorator.new(component)
27
+ end
28
+ end
29
+
30
+ class CachedChunksComponent
31
+ def initialize(chunks)
32
+ @chunks = chunks
33
+ end
34
+
35
+ def each_chunk(&block)
36
+ return enum_for(:each_chunk) unless block
37
+
38
+ @chunks.each(&block)
39
+ end
40
+ end
41
+
42
+ class CachingComponent
43
+ def initialize(upstream_stream, cache_key, cache_options)
44
+ @upstream_stream = upstream_stream
45
+ @cache_key = cache_key
46
+ @cache_options = cache_options
47
+ end
48
+
49
+ def each_chunk(&block)
50
+ return enum_for(:each_chunk) unless block
51
+
52
+ buffered_chunks = []
53
+ @upstream_stream.each_chunk do |chunk|
54
+ buffered_chunks << chunk
55
+ yield(chunk)
56
+ end
57
+ Rails.cache.write(@cache_key, buffered_chunks, @cache_options || {})
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRailsPro
4
+ class StreamDecorator
5
+ def initialize(component)
6
+ @component = component
7
+ # @type [Array[Proc]]
8
+ # Proc receives 2 arguments: chunk, position
9
+ # @param chunk [String] The chunk to be processed
10
+ # @param position [Symbol] The position of the chunk in the stream (:first, :middle, or :last)
11
+ # The position parameter is used by actions that add content to the beginning or end of the stream
12
+ @actions = [] # List to store all actions
13
+ @rescue_blocks = []
14
+ end
15
+
16
+ # Add a prepend action
17
+ def prepend
18
+ @actions << ->(chunk, position) { position == :first ? "#{yield}#{chunk}" : chunk }
19
+ self # Return self to allow chaining
20
+ end
21
+
22
+ # Add a transformation action
23
+ def transform
24
+ @actions << lambda { |chunk, position|
25
+ if position == :last && chunk.empty?
26
+ # Return the empty chunk without modification for the last chunk
27
+ # This is related to the `handleChunk(:last, "")` call which gets all the appended content
28
+ # We don't want to make an extra call to the transformer block if there is no content appended
29
+ chunk
30
+ else
31
+ yield(chunk)
32
+ end
33
+ }
34
+ self # Return self to allow chaining
35
+ end
36
+
37
+ # Add an append action
38
+ def append
39
+ @actions << ->(chunk, position) { position == :last ? "#{chunk}#{yield}" : chunk }
40
+ self # Return self to allow chaining
41
+ end
42
+
43
+ def rescue(&block)
44
+ @rescue_blocks << block
45
+ self # Return self to allow chaining
46
+ end
47
+
48
+ def handle_chunk(chunk, position)
49
+ @actions.reduce(chunk) do |acc, action|
50
+ action.call(acc, position)
51
+ end
52
+ end
53
+
54
+ def each_chunk(&block) # rubocop:disable Metrics/CyclomaticComplexity
55
+ return enum_for(:each_chunk) unless block
56
+
57
+ first_chunk = true
58
+ @component.each_chunk do |chunk|
59
+ position = first_chunk ? :first : :middle
60
+ modified_chunk = handle_chunk(chunk, position)
61
+ yield(modified_chunk)
62
+ first_chunk = false
63
+ end
64
+
65
+ # The last chunk contains the append content after the transformation
66
+ # All transformations are applied to the append content
67
+ last_chunk = handle_chunk("", :last)
68
+ yield(last_chunk) unless last_chunk.empty?
69
+ rescue StandardError => e
70
+ current_error = e
71
+ rescue_block_index = 0
72
+ while current_error.present? && (rescue_block_index < @rescue_blocks.size)
73
+ begin
74
+ @rescue_blocks[rescue_block_index].call(current_error, &block)
75
+ current_error = nil
76
+ rescue StandardError => inner_error
77
+ current_error = inner_error
78
+ end
79
+ rescue_block_index += 1
80
+ end
81
+ raise current_error if current_error.present?
82
+ end
83
+ end
84
+
85
+ class StreamRequest
86
+ def initialize(&request_block)
87
+ @request_executor = request_block
88
+ end
89
+
90
+ private_class_method :new
91
+
92
+ def each_chunk(&block)
93
+ return enum_for(:each_chunk) unless block
94
+
95
+ send_bundle = false
96
+ error_body = +""
97
+ loop do
98
+ stream_response = @request_executor.call(send_bundle)
99
+
100
+ # Chunks can be merged during streaming, so we separate them by newlines
101
+ # Also, we check the status code inside the loop block because calling `status` outside the loop block
102
+ # is blocking, it will wait for the response to be fully received
103
+ # Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details
104
+ process_response_chunks(stream_response, error_body, &block)
105
+ break
106
+ rescue HTTPX::HTTPError => e
107
+ send_bundle = handle_http_error(e, error_body, send_bundle)
108
+ rescue HTTPX::ReadTimeoutError => e
109
+ raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \
110
+ "Original error:\n#{e}\n#{e.backtrace}"
111
+ end
112
+ end
113
+
114
+ def process_response_chunks(stream_response, error_body)
115
+ loop_response_lines(stream_response) do |chunk|
116
+ if stream_response.is_a?(HTTPX::ErrorResponse) || stream_response.status >= 400
117
+ error_body << chunk
118
+ next
119
+ end
120
+
121
+ processed_chunk = chunk.strip
122
+ yield processed_chunk unless processed_chunk.empty?
123
+ end
124
+ end
125
+
126
+ def handle_http_error(error, error_body, send_bundle)
127
+ response = error.response
128
+ case response.status
129
+ when ReactOnRailsPro::STATUS_SEND_BUNDLE
130
+ # To prevent infinite loop
131
+ ReactOnRailsPro::Error.raise_duplicate_bundle_upload_error if send_bundle
132
+
133
+ true
134
+ when ReactOnRailsPro::STATUS_INCOMPATIBLE
135
+ raise ReactOnRailsPro::Error, error_body
136
+ else
137
+ raise ReactOnRailsPro::Error, "Unexpected response code from renderer: #{response.status}:\n#{error_body}"
138
+ end
139
+ end
140
+
141
+ # Method to start the decoration
142
+ def self.create(&request_block)
143
+ StreamDecorator.new(new(&request_block))
144
+ end
145
+
146
+ private
147
+
148
+ # This method is considered as an override of response.each_line
149
+ # It fixes the problem of not yielding the last chunk on error
150
+ # You can check the spec of `each_line` in `spec/react_on_rails_pro/stream_spec.rb` for more details
151
+ def loop_response_lines(response)
152
+ return enum_for(__method__, response) unless block_given?
153
+
154
+ line = "".b
155
+
156
+ response.each do |chunk|
157
+ response.instance_variable_set(:@react_on_rails_received_first_chunk, true)
158
+ line << chunk
159
+
160
+ while (idx = line.index("\n"))
161
+ yield line.byteslice(0..idx - 1)
162
+
163
+ line = line.byteslice(idx + 1..-1)
164
+ end
165
+ end
166
+ ensure
167
+ yield line unless line.empty?
168
+ end
169
+ end
170
+ end