universal_renderer 0.4.3 → 0.5.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 551cd8e80275fa95b8c3f66672f4a4262a25f5f0038fe4c45375b9bc852fbabe
4
- data.tar.gz: bad53819a7dfdbdb10fbe1d3317e3877fcb05dc4fc2663ed5954e4975d3bd5b3
3
+ metadata.gz: f0be3b52440e167e802130a84490b9c7a35f8d0d75ebbed0803f722edeec699b
4
+ data.tar.gz: 1c9b6213063855a08b0a3cc54771ad99c4679e2ea03f9c9382c181990bdc2db1
5
5
  SHA512:
6
- metadata.gz: 0eecc4335c6c69929077c77db58fd22e7dbc9db04eeb78efa16826e2b8a35425c270990c96141adf842db4a5d725eacda04f277edbab60f7df41b0580a9b1252
7
- data.tar.gz: 4df736ee5b781353f2543f00badc3efbdecd1eebb66a2e6405ff8136599a0f1136ef2fc52dc2d27732cbf7c84bf15a3eb904d3303e7367e1b7c8923fdf0d176c
6
+ metadata.gz: 45f4c30e25d549e7dd4641179217d7e5b4b0e89819be029cab274aebfd79f3278b48d9392a32b4be719ae81d7bc80a8b8f3edb03dde05b8beb0bd03d3e3338ae
7
+ data.tar.gz: db4f6173873b402d12c602b6211e0ffe7a46fee8b76fff5fffd7b0fd86e27d05d2dca6792ec208906109e4735a4cbdd0ec779951f4176cd5d0b314b2aa96961d
data/README.md CHANGED
@@ -33,6 +33,7 @@ UniversalRenderer helps you forward rendering requests to external SSR services,
33
33
  ```
34
34
 
35
35
  3. Run the generator:
36
+
36
37
  ```bash
37
38
  $ rails generate universal_renderer:install
38
39
  ```
@@ -42,60 +43,31 @@ UniversalRenderer helps you forward rendering requests to external SSR services,
42
43
  Configure in `config/initializers/universal_renderer.rb`:
43
44
 
44
45
  ```ruby
45
- UniversalRenderer.configure do |config|
46
- config.ssr_url = "http://localhost:3001"
46
+ UniversalRenderer.configure do |c|
47
+ c.url = "http://localhost:3001"
48
+ c.timeout = 3
49
+ c.stream_path = "/stream"
50
+ c.http.pool_size = 5 # persistent Net::HTTP connections per SSR origin
51
+
52
+ # Blocking SSR is the default. Enable streaming per controller only when needed:
53
+ # enable_ssr streaming: true
47
54
  end
48
55
  ```
49
56
 
50
- ## Basic Usage
51
-
52
- After installation, you can pass data to your SSR service using `add_prop` in your controllers:
57
+ The gem itself never reads environment variables; it only exposes the plain
58
+ configuration attributes above with sensible defaults. Binding those values to
59
+ `ENV` is entirely up to you, in your own initializer, using whatever keys you
60
+ like. If you want a convention, the suggested env-var prefix is
61
+ `UNIVERSAL_RENDERER_*` (e.g. `UNIVERSAL_RENDERER_URL`):
53
62
 
54
63
  ```ruby
55
- class ProductsController < ApplicationController
56
- enable_ssr # enables SSR controller-wide
57
-
58
- def show
59
- @product = Product.find(params[:id])
60
-
61
- # We can use the provided add_prop method to set a single value.
62
- add_prop(:product, @product.as_json)
63
-
64
- # We can use the provided push_prop method to push multiple values to an array.
65
- # This is useful for pushing data to React Query.
66
- push_prop(:query_data, { key: ["currentUser"], data: current_user.as_json })
67
-
68
- fetch_ssr # or fetch on demand
69
-
70
- # @ssr will now contain a UniversalRenderer::SSR::Response which exposes
71
- # `.head`, `.body` and optional `.body_attrs` values returned by the SSR
72
- # service.
73
- end
74
-
75
- def default_render
76
- # If you want to re-use the same layout across multiple actions.
77
- # You can also put this in your ApplicationController.
78
- render "ssr/index"
79
- end
64
+ UniversalRenderer.configure do |c|
65
+ c.url = ENV.fetch("UNIVERSAL_RENDERER_URL", "http://localhost:3001")
66
+ c.timeout = ENV.fetch("UNIVERSAL_RENDERER_TIMEOUT", 3).to_i
67
+ # ...
80
68
  end
81
69
  ```
82
70
 
83
- ```erb
84
- <%# "ssr/index" %>
85
-
86
- <%# Inject SSR snippets using the provided helpers %>
87
- <%# When streaming is enabled these render HTML placeholders %>
88
- <%# Otherwise they output the sanitised HTML returned by the SSR service %>
89
-
90
- <%= content_for :head do %>
91
- <%= ssr_head %>
92
- <% end %>
93
-
94
- <div id="root">
95
- <%= ssr_body %>
96
- </div>
97
- ```
98
-
99
71
  ## Setting Up the SSR Server
100
72
 
101
73
  To set up the SSR server for your Rails application:
@@ -106,8 +78,6 @@ To set up the SSR server for your Rails application:
106
78
  $ npm install universal-renderer
107
79
  # or
108
80
  $ yarn add universal-renderer
109
- # or
110
- $ bun add universal-renderer
111
81
  ```
112
82
 
113
83
  2. Create a `setup` function at `app/frontend/ssr/setup.ts`:
@@ -223,6 +193,28 @@ To set up the SSR server for your Rails application:
223
193
  ssr: bin/vite ssr
224
194
  ```
225
195
 
196
+ ## Development
197
+
198
+ To contribute to this project:
199
+
200
+ 1. Clone the repository:
201
+
202
+ ```bash
203
+ git clone https://github.com/thaske/universal_renderer.git
204
+ cd universal_renderer
205
+ ```
206
+
207
+ 2. Initialize and update submodules:
208
+
209
+ ```bash
210
+ git submodule update --init --recursive
211
+ ```
212
+
213
+ 3. Install dependencies:
214
+ ```bash
215
+ bundle install
216
+ ```
217
+
226
218
  ## Contributing
227
219
 
228
220
  Contributions are welcome! Please follow the coding guidelines in the project documentation.
data/Rakefile CHANGED
@@ -1,8 +1,11 @@
1
1
  require "bundler/setup"
2
2
 
3
3
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
- load "rails/tasks/engine.rake"
5
-
6
- load "rails/tasks/statistics.rake"
4
+ if File.exist?(APP_RAKEFILE)
5
+ load "rails/tasks/engine.rake"
6
+ load "rails/tasks/statistics.rake"
7
+ end
7
8
 
8
9
  require "bundler/gem_tasks"
10
+
11
+ Dir[File.expand_path("lib/tasks/**/*.rake", __dir__)].each { |task| load task }
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module UniversalRenderer
2
4
  class InstallGenerator < Rails::Generators::Base
3
5
  source_root File.expand_path("templates", __dir__)
@@ -5,5 +7,13 @@ module UniversalRenderer
5
7
  def copy_initializer
6
8
  template "initializer.rb", "config/initializers/universal_renderer.rb"
7
9
  end
10
+
11
+ def show_installation_notes
12
+ say_status "info", "Universal Renderer installed successfully!"
13
+ say_status "note", "Next steps:"
14
+ say_status "",
15
+ " 1. Edit config/initializers/universal_renderer.rb to point at your SSR server"
16
+ say_status "", " 2. Ensure your Node.js/Bun SSR server is running"
17
+ end
8
18
  end
9
19
  end
@@ -1,4 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  UniversalRenderer.configure do |c|
2
- c.ssr_url = ENV.fetch("SSR_SERVER_URL", "http://localhost:3001")
4
+ # External Node.js/Bun SSR server. Supports streaming via the /stream endpoint.
5
+ c.url = "http://localhost:3001"
3
6
  c.timeout = 3
7
+ c.stream_path = "/stream"
8
+ c.http.pool_size = 5
9
+
10
+ # Blocking SSR is the default. Enable streaming per controller only when needed:
11
+ # enable_ssr streaming: true
4
12
  end
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
+ require_relative "http_pool"
6
7
 
7
8
  module UniversalRenderer
8
9
  module Client
@@ -24,23 +25,18 @@ module UniversalRenderer
24
25
  # (HTTP 2xx). Returns `nil` when the request fails or the SSR service is
25
26
  # unreachable.
26
27
  def self.call(url, props)
27
- ssr_url = UniversalRenderer.config.ssr_url
28
+ ssr_url = UniversalRenderer.config.url
28
29
  return if ssr_url.blank?
29
30
 
30
31
  timeout = UniversalRenderer.config.timeout
31
32
 
32
33
  begin
33
34
  uri = URI.parse(ssr_url)
34
- http = Net::HTTP.new(uri.host, uri.port)
35
- http.use_ssl = (uri.scheme == "https")
36
- http.open_timeout = timeout
37
- http.read_timeout = timeout
38
-
39
35
  request = Net::HTTP::Post.new(uri.request_uri)
40
36
  request.body = { url: url, props: props }.to_json
41
37
  request["Content-Type"] = "application/json"
42
38
 
43
- response = http.request(request)
39
+ response = HttpPool.request(uri, timeout, request)
44
40
 
45
41
  if response.is_a?(Net::HTTPSuccess)
46
42
  raw_data = JSON.parse(response.body).deep_symbolize_keys
@@ -54,20 +50,26 @@ module UniversalRenderer
54
50
  body_attrs: raw_data[:body_attrs]
55
51
  )
56
52
  else
57
- Rails.logger.error(
58
- "SSR fetch request to #{ssr_url} failed: #{response.code} - #{response.message} (URL: #{url})"
59
- )
53
+ UniversalRenderer.log do |log|
54
+ log.error(
55
+ "SSR fetch request to #{ssr_url} failed: #{response.code} - #{response.message} (URL: #{url})"
56
+ )
57
+ end
60
58
  nil
61
59
  end
62
60
  rescue Net::OpenTimeout, Net::ReadTimeout => e
63
- Rails.logger.error(
64
- "SSR fetch request to #{ssr_url} timed out: #{e.class.name} - #{e.message} (URL: #{url})"
65
- )
61
+ UniversalRenderer.log do |log|
62
+ log.error(
63
+ "SSR fetch request to #{ssr_url} timed out: #{e.class.name} - #{e.message} (URL: #{url})"
64
+ )
65
+ end
66
66
  nil
67
67
  rescue StandardError => e
68
- Rails.logger.error(
69
- "SSR fetch request to #{ssr_url} failed: #{e.class.name} - #{e.message} (URL: #{url})"
70
- )
68
+ UniversalRenderer.log do |log|
69
+ log.error(
70
+ "SSR fetch request to #{ssr_url} failed: #{e.class.name} - #{e.message} (URL: #{url})"
71
+ )
72
+ end
71
73
  nil
72
74
  end
73
75
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connection_pool"
4
+ require "net/http"
5
+
6
+ module UniversalRenderer
7
+ module Client
8
+ # Thread-safe pool of persistent Net::HTTP connections keyed by SSR origin.
9
+ #
10
+ # Net::HTTP instances are not thread-safe, so each pooled connection is
11
+ # checked out exclusively. Keeping them started allows Net::HTTP to reuse
12
+ # the underlying TCP connection for sequential SSR requests.
13
+ class HttpPool
14
+ class ClientProxy
15
+ def initialize(uri, timeout)
16
+ @uri = uri
17
+ @timeout = timeout
18
+ end
19
+
20
+ def request(http_request, &block)
21
+ if block
22
+ HttpPool.request(@uri, @timeout, http_request) do |response, http|
23
+ block.call(response, http)
24
+ end
25
+ else
26
+ HttpPool.request(@uri, @timeout, http_request)
27
+ end
28
+ end
29
+ end
30
+
31
+ class << self
32
+ def client(uri, timeout)
33
+ ClientProxy.new(uri, timeout)
34
+ end
35
+
36
+ def request(uri, timeout, http_request)
37
+ with_connection(uri, timeout) do |http|
38
+ if block_given?
39
+ http.request(http_request) { |response| yield(response, http) }
40
+ else
41
+ http.request(http_request)
42
+ end
43
+ end
44
+ end
45
+
46
+ def reset!
47
+ mutex.synchronize do
48
+ pools.each_value { |pool| pool.shutdown { |http| close(http) } }
49
+ pools.clear
50
+ end
51
+ end
52
+
53
+ def close(http)
54
+ http.finish if http.started?
55
+ rescue IOError
56
+ # Already closed by the peer or by another cleanup path.
57
+ end
58
+
59
+ private
60
+
61
+ def with_connection(uri, timeout)
62
+ pool_for(uri, timeout).with do |http|
63
+ ensure_started(http)
64
+ yield http
65
+ rescue StandardError
66
+ close(http)
67
+ raise
68
+ end
69
+ end
70
+
71
+ def pool_for(uri, timeout)
72
+ key = pool_key(uri, timeout)
73
+
74
+ mutex.synchronize do
75
+ pools[key] ||= ConnectionPool.new(
76
+ size: pool_size,
77
+ timeout: timeout
78
+ ) { build_connection(uri, timeout) }
79
+ end
80
+ end
81
+
82
+ def build_connection(uri, timeout)
83
+ Net::HTTP
84
+ .new(uri.host, uri.port)
85
+ .tap do |http|
86
+ http.use_ssl = (uri.scheme == "https")
87
+ http.open_timeout = timeout
88
+ http.read_timeout = timeout
89
+ end
90
+ end
91
+
92
+ def ensure_started(http)
93
+ http.start unless http.started?
94
+ end
95
+
96
+ def pool_key(uri, timeout)
97
+ [uri.scheme, uri.host, uri.port, timeout, pool_size].join(":")
98
+ end
99
+
100
+ def pool_size
101
+ UniversalRenderer.config.http.pool_size
102
+ end
103
+
104
+ def pools
105
+ @pools ||= {}
106
+ end
107
+
108
+ def mutex
109
+ @mutex ||= Mutex.new
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -4,24 +4,30 @@ module UniversalRenderer
4
4
  module ErrorLogger
5
5
  def self.log_setup_error(error, target_uri_string)
6
6
  backtrace_info = error.backtrace&.first || "No backtrace available"
7
- Rails.logger.error(
8
- "Unexpected error during SSR stream setup for #{target_uri_string}: " \
9
- "#{error.class.name} - #{error.message} at #{backtrace_info}"
10
- )
7
+ UniversalRenderer.log do |log|
8
+ log.error(
9
+ "Unexpected error during SSR stream setup for #{target_uri_string}: " \
10
+ "#{error.class.name} - #{error.message} at #{backtrace_info}"
11
+ )
12
+ end
11
13
  end
12
14
 
13
15
  def self.log_connection_error(error, target_uri_string)
14
- Rails.logger.error(
15
- "SSR stream connection to #{target_uri_string} failed: #{error.class.name} - #{error.message}"
16
- )
16
+ UniversalRenderer.log do |log|
17
+ log.error(
18
+ "SSR stream connection to #{target_uri_string} failed: #{error.class.name} - #{error.message}"
19
+ )
20
+ end
17
21
  end
18
22
 
19
23
  def self.log_unexpected_error(error, target_uri_string, context_message)
20
24
  backtrace_info = error.backtrace&.first || "No backtrace available"
21
- Rails.logger.error(
22
- "#{context_message} for #{target_uri_string}: " \
23
- "#{error.class.name} - #{error.message} at #{backtrace_info}"
24
- )
25
+ UniversalRenderer.log do |log|
26
+ log.error(
27
+ "#{context_message} for #{target_uri_string}: " \
28
+ "#{error.class.name} - #{error.message} at #{backtrace_info}"
29
+ )
30
+ end
25
31
  end
26
32
  end
27
33
  end
@@ -1,3 +1,5 @@
1
+ require_relative "../http_pool"
2
+
1
3
  module UniversalRenderer
2
4
  module Client
3
5
  class Stream
@@ -8,29 +10,48 @@ module UniversalRenderer
8
10
  response,
9
11
  stream_uri
10
12
  )
11
- http_client.request(http_post_request) do |node_res|
12
- if node_res.is_a?(Net::HTTPSuccess)
13
- node_res.read_body { |chunk| response.stream.write(chunk) }
14
- else
15
- Rails.logger.error(
16
- "SSR stream server at #{stream_uri} responded with #{node_res.code} #{node_res.message}."
17
- )
13
+ success = false
14
+ chunks_written = false
15
+ upstream_connection = nil
18
16
 
19
- # Close stream without forwarding error to allow fallback to client rendering
20
- response.stream.close unless response.stream.closed?
17
+ begin
18
+ http_client.request(http_post_request) do |node_res, connection|
19
+ upstream_connection = connection
21
20
 
22
- false
21
+ if node_res.is_a?(Net::HTTPSuccess)
22
+ node_res.read_body do |chunk|
23
+ # A stream write can fail after handing bytes to the response
24
+ # buffer. Mark it first so the caller never falls back onto a
25
+ # potentially partial response.
26
+ chunks_written = true
27
+ response.stream.write(chunk)
28
+ end
29
+ success = true
30
+ else
31
+ UniversalRenderer.log do |log|
32
+ log.error(
33
+ "SSR stream server at #{stream_uri} responded with #{node_res.code} #{node_res.message}."
34
+ )
35
+ end
36
+ end
23
37
  end
24
38
  rescue StandardError => e
25
- Rails.logger.error(
26
- "Error during SSR data transfer or stream writing from #{stream_uri}: #{e.class.name} - #{e.message}"
27
- )
28
- false
39
+ UniversalRenderer.log do |log|
40
+ log.error(
41
+ "Error during SSR data transfer or stream writing from #{stream_uri}: #{e.class.name} - #{e.message}"
42
+ )
43
+ end
44
+
45
+ # The response may be only partially consumed, so do not reuse the
46
+ # current persistent connection. Net::HTTP can also raise after its
47
+ # request block returns while it finalizes that response body.
48
+ HttpPool.close(upstream_connection) if upstream_connection
49
+ success = chunks_written
29
50
  ensure
30
- response.stream.close unless response.stream.closed?
51
+ response.stream.close if success && !response.stream.closed?
31
52
  end
32
53
 
33
- true
54
+ success
34
55
  end
35
56
  end
36
57
  end
@@ -1,25 +1,22 @@
1
+ require_relative "../http_pool"
2
+
1
3
  module UniversalRenderer
2
4
  module Client
3
5
  class Stream
4
6
  module Setup
5
7
  def self.ensure_ssr_server_url_configured?(config)
6
- config.ssr_url.present?
8
+ config.url.present?
7
9
  end
8
10
 
9
11
  def self.build_stream_request_components(body, config)
10
12
  # Ensure ssr_url is present, though ensure_ssr_server_url_configured? should have caught this.
11
- # However, direct calls to this method might occur, so a check or reliance on config.ssr_url is important.
12
- if config.ssr_url.blank?
13
- raise ArgumentError, "SSR URL is not configured."
14
- end
13
+ # However, direct calls to this method might occur, so a check or reliance on config.url is important.
14
+ raise ArgumentError, "SSR URL is not configured." if config.url.blank?
15
15
 
16
- parsed_ssr_url = URI.parse(config.ssr_url)
17
- stream_uri = URI.join(parsed_ssr_url, config.ssr_stream_path)
16
+ parsed_ssr_url = URI.parse(config.url)
17
+ stream_uri = URI.join(parsed_ssr_url, config.stream_path)
18
18
 
19
- http = Net::HTTP.new(stream_uri.host, stream_uri.port)
20
- http.use_ssl = (stream_uri.scheme == "https")
21
- http.open_timeout = config.timeout
22
- http.read_timeout = config.timeout
19
+ http = HttpPool.client(stream_uri, config.timeout)
23
20
 
24
21
  http_request =
25
22
  Net::HTTP::Post.new(
@@ -22,14 +22,16 @@ module UniversalRenderer
22
22
  config = UniversalRenderer.config
23
23
 
24
24
  unless Setup.ensure_ssr_server_url_configured?(config)
25
- Rails.logger.warn(
26
- "Stream: SSR URL (config.ssr_url) is not configured. Falling back."
27
- )
25
+ UniversalRenderer.log do |log|
26
+ log.warn(
27
+ "Stream: SSR URL (config.url) is not configured. Falling back."
28
+ )
29
+ end
28
30
  return false
29
31
  end
30
32
 
31
33
  stream_uri_obj = nil
32
- full_ssr_url_for_log = config.ssr_url.to_s # For logging in case of early error
34
+ full_ssr_url_for_log = config.url.to_s # For logging in case of early error
33
35
 
34
36
  begin
35
37
  body = { url: url, props: props, template: template }
@@ -41,9 +43,11 @@ module UniversalRenderer
41
43
 
42
44
  full_ssr_url_for_log = actual_stream_uri.to_s # Update for more specific logging
43
45
  rescue URI::InvalidURIError => e
44
- Rails.logger.error(
45
- "Stream: SSR stream failed due to invalid URI ('#{config.ssr_url}'): #{e.message}"
46
- )
46
+ UniversalRenderer.log do |log|
47
+ log.error(
48
+ "Stream: SSR stream failed due to invalid URI ('#{config.url}'): #{e.message}"
49
+ )
50
+ end
47
51
  return false
48
52
  rescue StandardError => e
49
53
  log_setup_error(e, full_ssr_url_for_log)
@@ -1,11 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module UniversalRenderer
4
+ # Configuration for UniversalRenderer.
5
+ #
6
+ # This object holds plain Ruby defaults only. It never reads environment
7
+ # variables itself; binding configuration to ENV is the host application's
8
+ # responsibility, done in the initializer (see the generated
9
+ # config/initializers/universal_renderer.rb). The documented env-var
10
+ # convention is the `UNIVERSAL_RENDERER_*` prefix.
2
11
  class Configuration
3
- attr_accessor :ssr_url, :timeout, :ssr_stream_path
12
+ # HTTP client options.
13
+ class Http
14
+ attr_accessor :pool_size
15
+
16
+ def initialize
17
+ @pool_size = 5
18
+ end
19
+ end
20
+
21
+ attr_accessor :url, :timeout, :stream_path
22
+ attr_reader :http
4
23
 
5
24
  def initialize
6
- @ssr_url = ENV.fetch("SSR_SERVER_URL", nil)
7
- @timeout = (ENV["SSR_TIMEOUT"] || 3).to_i
8
- @ssr_stream_path = ENV.fetch("SSR_STREAM_PATH", "/stream")
25
+ @url = nil
26
+ @timeout = 3
27
+ @stream_path = "/stream"
28
+ @http = Http.new
9
29
  end
10
30
  end
11
31
  end
@@ -3,24 +3,37 @@ module UniversalRenderer
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- include ActionController::Live
7
6
  helper UniversalRenderer::SSR::Helpers
8
- before_action :initialize_props
7
+
8
+ # Distinct from the `enable_ssr` DSL method: reading a class_attribute
9
+ # named `enable_ssr` on a class that never opted in would invoke the DSL
10
+ # (arming SSR as a side effect) instead of returning false.
11
+ class_attribute :ssr_enabled, instance_writer: false, default: false
12
+ class_attribute :ssr_streaming_preference,
13
+ instance_writer: false,
14
+ default: nil
15
+
16
+ before_action :initialize_props, unless: :skip_universal_renderer?
17
+ end
18
+
19
+ module Streaming
20
+ extend ActiveSupport::Concern
21
+
22
+ included { include ActionController::Live }
9
23
  end
10
24
 
11
25
  class_methods do
12
26
  def enable_ssr(options = {})
13
- class_attribute :enable_ssr, instance_writer: false
14
- self.enable_ssr = true
15
-
16
- class_attribute :ssr_streaming_preference, instance_writer: false
27
+ self.ssr_enabled = true
17
28
  self.ssr_streaming_preference = options[:streaming]
29
+
30
+ include UniversalRenderer::Renderable::Streaming if options[:streaming]
18
31
  end
19
32
  end
20
33
 
21
34
  # Fetches Server-Side Rendered (SSR) content for the current request.
22
- # This method makes a blocking call to the SSR service using {UniversalRenderer::Client::Base.fetch}
23
- # and stores the result in the `@ssr` instance variable.
35
+ # This method makes a blocking call to the SSR service and stores the
36
+ # result in the `@ssr` instance variable.
24
37
  #
25
38
  # The SSR content is fetched based on the `request.original_url` and the
26
39
  # `@universal_renderer_props` accumulated for the request.
@@ -28,21 +41,24 @@ module UniversalRenderer
28
41
  # @return [Hash, nil] The fetched SSR data (typically a hash with keys like `:head`, `:body_html`, `:body_attrs`),
29
42
  # or `nil` if the fetch fails or SSR is not configured.
30
43
  def fetch_ssr
44
+ props = @universal_renderer_props || {}
31
45
  @ssr =
32
46
  UniversalRenderer::Client::Base.call(
33
47
  request.original_url,
34
- @universal_renderer_props
48
+ props
35
49
  )
36
50
  end
37
51
 
38
52
  def ssr_streaming?
39
- self.class.try(:ssr_streaming_preference)
53
+ self.class.ssr_streaming_preference
40
54
  end
41
55
 
42
56
  def render(*, **)
43
- return super unless self.class.enable_ssr
57
+ return super unless self.class.ssr_enabled
44
58
  return super unless request.format.html?
45
59
 
60
+ # Allow Warden and other authentication mechanisms to complete first
61
+ # This prevents interference with authentication throws like :warden
46
62
  if ssr_streaming?
47
63
  success = render_ssr_stream(*, **)
48
64
  super unless success
@@ -56,7 +72,7 @@ module UniversalRenderer
56
72
 
57
73
  def render_ssr_stream(*, **)
58
74
  full_layout = render_to_string(*, **)
59
- current_props = @universal_renderer_props.dup
75
+ current_props = (@universal_renderer_props || {}).dup
60
76
 
61
77
  streaming_succeeded =
62
78
  UniversalRenderer::Client::Stream.call(
@@ -66,14 +82,16 @@ module UniversalRenderer
66
82
  response
67
83
  )
68
84
 
69
- # SSR streaming failed or was not possible (e.g. server down, config missing).
70
85
  if streaming_succeeded
71
86
  response.stream.close unless response.stream.closed?
87
+ true
72
88
  else
73
- Rails.logger.error(
74
- "SSR stream fallback: " \
75
- "Streaming failed, proceeding with standard rendering."
76
- )
89
+ UniversalRenderer.log do |log|
90
+ log.error(
91
+ "SSR stream fallback: " \
92
+ "Streaming failed, proceeding with standard rendering."
93
+ )
94
+ end
77
95
  false
78
96
  end
79
97
  end
@@ -82,6 +100,22 @@ module UniversalRenderer
82
100
  @universal_renderer_props = {}
83
101
  end
84
102
 
103
+ # Determines whether to skip universal renderer initialization
104
+ # This helps prevent interference with authentication flows like Warden
105
+ def skip_universal_renderer?
106
+ # Skip if SSR is not enabled for this controller
107
+ return true unless self.class.ssr_enabled
108
+
109
+ # Skip for non-HTML requests
110
+ return true unless request.format.html?
111
+
112
+ # Only skip if we're in the middle of an active Warden throw/catch mechanism
113
+ # This is more specific and allows SSR for public pages with unauthenticated users
114
+ return true if defined?(Warden) && request.env["warden"]&.message.present?
115
+
116
+ false
117
+ end
118
+
85
119
  # Adds a prop or a hash of props to be sent to the SSR service.
86
120
  # Props are deep-stringified if a hash is provided.
87
121
  #
@@ -94,6 +128,7 @@ module UniversalRenderer
94
128
  # add_prop({theme: "dark", locale: "en"})
95
129
  # @return [void]
96
130
  def add_prop(key_or_hash, data_value = nil)
131
+ @universal_renderer_props ||= {}
97
132
  if data_value.nil? && key_or_hash.is_a?(Hash)
98
133
  @universal_renderer_props.merge!(key_or_hash.deep_stringify_keys)
99
134
  else
@@ -119,6 +154,7 @@ module UniversalRenderer
119
154
  # push_prop(:item, "second") # @universal_renderer_props becomes { "item" => ["first", "second"] }
120
155
  # @return [void]
121
156
  def push_prop(key, value_to_add)
157
+ @universal_renderer_props ||= {}
122
158
  prop_key = key.to_s
123
159
  current_value = @universal_renderer_props[prop_key]
124
160
 
@@ -135,5 +171,18 @@ module UniversalRenderer
135
171
  @universal_renderer_props[prop_key] << value_to_add
136
172
  end
137
173
  end
174
+
175
+ # Adds a React Query cache entry that can be hydrated on SSR/client boot.
176
+ #
177
+ # @param query_key [Array, String, Symbol] The React Query key.
178
+ # @param data [Object] The cached query data.
179
+ # @return [void]
180
+ def add_query_data(query_key, data)
181
+ normalized_query_key = query_key.is_a?(Array) ? query_key : [query_key]
182
+ push_prop(
183
+ :react_query,
184
+ { query_key: normalized_query_key, data: data }.deep_stringify_keys
185
+ )
186
+ end
138
187
  end
139
188
  end
@@ -40,6 +40,30 @@ module UniversalRenderer
40
40
  sanitize(html, scrubber: Scrubber.new)
41
41
  end
42
42
 
43
+ # @!method ssr_props_json(props = nil)
44
+ # Serializes and JSON-escapes SSR props for safe embedding in HTML attributes.
45
+ # Defaults to `@universal_renderer_props` from the current controller/view context.
46
+ # @param props [Hash, nil] Optional props hash to serialize.
47
+ # @return [String] JSON string escaped for safe HTML embedding.
48
+ def ssr_props_json(props = nil)
49
+ raw_props = props || @universal_renderer_props || {}
50
+ ERB::Util.json_escape(raw_props.to_json)
51
+ end
52
+
53
+ # @!method ssr_props(id: "ssr-props", props: nil)
54
+ # Renders a JSON script tag containing SSR props for client hydration.
55
+ # @param id [String] DOM id for the script element.
56
+ # @param props [Hash, nil] Optional props hash to serialize.
57
+ # @return [ActiveSupport::SafeBuffer] Script tag with serialized JSON payload.
58
+ def ssr_props(id: "ssr-props", props: nil)
59
+ content_tag(
60
+ :script,
61
+ ssr_props_json(props),
62
+ { id: id, type: "application/json" },
63
+ false
64
+ )
65
+ end
66
+
43
67
  # @!method ssr_streaming?
44
68
  # Determines if SSR streaming should be used for the current request.
45
69
  # The decision is based solely on the `ssr_streaming_preference` class attribute
@@ -1,3 +1,3 @@
1
1
  module UniversalRenderer
2
- VERSION = "0.4.3".freeze
2
+ VERSION = "0.5.0".freeze
3
3
  end
@@ -24,5 +24,39 @@ module UniversalRenderer
24
24
  def configure
25
25
  yield(config)
26
26
  end
27
+
28
+ # Configurable logger for the gem. Defaults to the host application's
29
+ # Rails.logger (when Rails is loaded) wrapped in
30
+ # ActiveSupport::TaggedLogging so every line is prefixed with
31
+ # [UniversalRenderer]. Host apps can replace it via
32
+ # UniversalRenderer.logger = MyLogger.new.
33
+ def logger
34
+ @logger || default_logger
35
+ end
36
+
37
+ def logger=(logger)
38
+ @logger =
39
+ logger.nil? ? nil : ActiveSupport::TaggedLogging.new(logger)
40
+ end
41
+
42
+ # Tags every block of log output with the gem name so host app logs stay
43
+ # distinguishable without callers having to remember a prefix. Yields the
44
+ # tagged logger for convenient use:
45
+ # UniversalRenderer.log { |log| log.error("...") }
46
+ def log
47
+ logger.tagged("UniversalRenderer") { yield logger }
48
+ end
49
+
50
+ private
51
+
52
+ def default_logger
53
+ base =
54
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
55
+ Rails.logger
56
+ else
57
+ ActiveSupport::Logger.new($stdout)
58
+ end
59
+ ActiveSupport::TaggedLogging.new(base)
60
+ end
27
61
  end
28
62
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: universal_renderer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thaske
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: connection_pool
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: loofah
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -59,6 +73,7 @@ files:
59
73
  - lib/tasks/universal_renderer_tasks.rake
60
74
  - lib/universal_renderer.rb
61
75
  - lib/universal_renderer/client/base.rb
76
+ - lib/universal_renderer/client/http_pool.rb
62
77
  - lib/universal_renderer/client/stream.rb
63
78
  - lib/universal_renderer/client/stream/error_logger.rb
64
79
  - lib/universal_renderer/client/stream/execution.rb
@@ -93,7 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
108
  - !ruby/object:Gem::Version
94
109
  version: '0'
95
110
  requirements: []
96
- rubygems_version: 3.6.9
111
+ rubygems_version: 4.0.8
97
112
  specification_version: 4
98
113
  summary: Facilitates Server-Side Rendering (SSR) in Rails applications.
99
114
  test_files: []