universal_renderer 0.4.4 → 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: 40fcb0b84e6de636246a3b1d47e128d511bc0246b6a871fa2250a30907463987
4
- data.tar.gz: 53b3200c0b02084ee00a534cbbc22513b9d616ff32ab9fcc98075a161495b1fd
3
+ metadata.gz: f0be3b52440e167e802130a84490b9c7a35f8d0d75ebbed0803f722edeec699b
4
+ data.tar.gz: 1c9b6213063855a08b0a3cc54771ad99c4679e2ea03f9c9382c181990bdc2db1
5
5
  SHA512:
6
- metadata.gz: 5e028737cb85c1e220e26afe9491e0b8f49e922cd7bf16af335ab0016fbed1b870eebd93d36e25503a70646a1f0c895b11f3f6d4840d452f70e02912eecc09d2
7
- data.tar.gz: 1c7d4b8708c07f631f7b43998a9d8083ca67737869557f4b26ef153a9911c9d2d28cab81e10824c5573912fc04507a7361080564105d0637ee217a26405bd9db
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,77 +43,29 @@ 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"
47
- end
48
- ```
49
-
50
- ## Basic Usage
51
-
52
- After installation, you can pass data to your SSR service using `add_prop` in your controllers:
53
-
54
- ### SSR Modes
55
-
56
- UniversalRenderer supports two SSR modes:
57
-
58
- 1. **Standard SSR** (default): Fetches complete HTML from the SSR service before rendering
59
- 2. **Streaming SSR**: Streams HTML content as it's generated (requires ActionController::Live)
60
-
61
- ```ruby
62
- # Standard SSR (recommended for most use cases)
63
- enable_ssr
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
64
51
 
65
- # Streaming SSR (only use if you need fast TTFB)
66
- enable_ssr streaming: true
67
- ```
68
-
69
- ```ruby
70
- class ProductsController < ApplicationController
71
- # Enable basic SSR (recommended for most use cases)
72
- enable_ssr
73
-
74
- # Or enable SSR with streaming (only if you need real-time rendering)
52
+ # Blocking SSR is the default. Enable streaming per controller only when needed:
75
53
  # enable_ssr streaming: true
76
-
77
- def show
78
- @product = Product.find(params[:id])
79
-
80
- # We can use the provided add_prop method to set a single value.
81
- add_prop(:product, @product.as_json)
82
-
83
- # We can use the provided push_prop method to push multiple values to an array.
84
- # This is useful for pushing data to React Query.
85
- push_prop(:query_data, { key: ["currentUser"], data: current_user.as_json })
86
-
87
- fetch_ssr # or fetch on demand
88
-
89
- # @ssr will now contain a UniversalRenderer::SSR::Response which exposes
90
- # `.head`, `.body` and optional `.body_attrs` values returned by the SSR
91
- # service.
92
- end
93
-
94
- def default_render
95
- # If you want to re-use the same layout across multiple actions.
96
- # You can also put this in your ApplicationController.
97
- render "ssr/index"
98
- end
99
54
  end
100
55
  ```
101
56
 
102
- ```erb
103
- <%# "ssr/index" %>
104
-
105
- <%# Inject SSR snippets using the provided helpers %>
106
- <%# When streaming is enabled these render HTML placeholders %>
107
- <%# Otherwise they output the sanitised HTML returned by the SSR service %>
108
-
109
- <%= content_for :head do %>
110
- <%= ssr_head %>
111
- <% end %>
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`):
112
62
 
113
- <div id="root">
114
- <%= ssr_body %>
115
- </div>
63
+ ```ruby
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
+ # ...
68
+ end
116
69
  ```
117
70
 
118
71
  ## Setting Up the SSR Server
@@ -125,8 +78,6 @@ To set up the SSR server for your Rails application:
125
78
  $ npm install universal-renderer
126
79
  # or
127
80
  $ yarn add universal-renderer
128
- # or
129
- $ bun add universal-renderer
130
81
  ```
131
82
 
132
83
  2. Create a `setup` function at `app/frontend/ssr/setup.ts`:
@@ -242,6 +193,28 @@ To set up the SSR server for your Rails application:
242
193
  ssr: bin/vite ssr
243
194
  ```
244
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
+
245
218
  ## Contributing
246
219
 
247
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
@@ -9,29 +11,44 @@ module UniversalRenderer
9
11
  stream_uri
10
12
  )
11
13
  success = false
14
+ chunks_written = false
15
+ upstream_connection = nil
12
16
 
13
- http_client.request(http_post_request) do |node_res|
14
- if node_res.is_a?(Net::HTTPSuccess)
15
- node_res.read_body { |chunk| response.stream.write(chunk) }
16
-
17
- success = true
18
- else
19
- Rails.logger.error(
20
- "SSR stream server at #{stream_uri} responded with #{node_res.code} #{node_res.message}."
21
- )
22
-
23
- # Close stream without forwarding error to allow fallback to client rendering
24
- response.stream.close unless response.stream.closed?
17
+ begin
18
+ http_client.request(http_post_request) do |node_res, connection|
19
+ upstream_connection = connection
25
20
 
26
- success = 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
27
37
  end
28
38
  rescue StandardError => e
29
- Rails.logger.error(
30
- "Error during SSR data transfer or stream writing from #{stream_uri}: #{e.class.name} - #{e.message}"
31
- )
32
- success = 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
33
50
  ensure
34
- response.stream.close unless response.stream.closed?
51
+ response.stream.close if success && !response.stream.closed?
35
52
  end
36
53
 
37
54
  success
@@ -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
@@ -4,7 +4,16 @@ module UniversalRenderer
4
4
 
5
5
  included do
6
6
  helper UniversalRenderer::SSR::Helpers
7
- 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?
8
17
  end
9
18
 
10
19
  module Streaming
@@ -15,10 +24,7 @@ module UniversalRenderer
15
24
 
16
25
  class_methods do
17
26
  def enable_ssr(options = {})
18
- class_attribute :enable_ssr, instance_writer: false
19
- self.enable_ssr = true
20
-
21
- class_attribute :ssr_streaming_preference, instance_writer: false
27
+ self.ssr_enabled = true
22
28
  self.ssr_streaming_preference = options[:streaming]
23
29
 
24
30
  include UniversalRenderer::Renderable::Streaming if options[:streaming]
@@ -26,8 +32,8 @@ module UniversalRenderer
26
32
  end
27
33
 
28
34
  # Fetches Server-Side Rendered (SSR) content for the current request.
29
- # This method makes a blocking call to the SSR service using {UniversalRenderer::Client::Base.fetch}
30
- # 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.
31
37
  #
32
38
  # The SSR content is fetched based on the `request.original_url` and the
33
39
  # `@universal_renderer_props` accumulated for the request.
@@ -35,21 +41,24 @@ module UniversalRenderer
35
41
  # @return [Hash, nil] The fetched SSR data (typically a hash with keys like `:head`, `:body_html`, `:body_attrs`),
36
42
  # or `nil` if the fetch fails or SSR is not configured.
37
43
  def fetch_ssr
44
+ props = @universal_renderer_props || {}
38
45
  @ssr =
39
46
  UniversalRenderer::Client::Base.call(
40
47
  request.original_url,
41
- @universal_renderer_props
48
+ props
42
49
  )
43
50
  end
44
51
 
45
52
  def ssr_streaming?
46
- self.class.try(:ssr_streaming_preference)
53
+ self.class.ssr_streaming_preference
47
54
  end
48
55
 
49
56
  def render(*, **)
50
- return super unless self.class.enable_ssr
57
+ return super unless self.class.ssr_enabled
51
58
  return super unless request.format.html?
52
59
 
60
+ # Allow Warden and other authentication mechanisms to complete first
61
+ # This prevents interference with authentication throws like :warden
53
62
  if ssr_streaming?
54
63
  success = render_ssr_stream(*, **)
55
64
  super unless success
@@ -63,7 +72,7 @@ module UniversalRenderer
63
72
 
64
73
  def render_ssr_stream(*, **)
65
74
  full_layout = render_to_string(*, **)
66
- current_props = @universal_renderer_props.dup
75
+ current_props = (@universal_renderer_props || {}).dup
67
76
 
68
77
  streaming_succeeded =
69
78
  UniversalRenderer::Client::Stream.call(
@@ -73,14 +82,16 @@ module UniversalRenderer
73
82
  response
74
83
  )
75
84
 
76
- # SSR streaming failed or was not possible (e.g. server down, config missing).
77
85
  if streaming_succeeded
78
86
  response.stream.close unless response.stream.closed?
87
+ true
79
88
  else
80
- Rails.logger.error(
81
- "SSR stream fallback: " \
82
- "Streaming failed, proceeding with standard rendering."
83
- )
89
+ UniversalRenderer.log do |log|
90
+ log.error(
91
+ "SSR stream fallback: " \
92
+ "Streaming failed, proceeding with standard rendering."
93
+ )
94
+ end
84
95
  false
85
96
  end
86
97
  end
@@ -89,6 +100,22 @@ module UniversalRenderer
89
100
  @universal_renderer_props = {}
90
101
  end
91
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
+
92
119
  # Adds a prop or a hash of props to be sent to the SSR service.
93
120
  # Props are deep-stringified if a hash is provided.
94
121
  #
@@ -101,6 +128,7 @@ module UniversalRenderer
101
128
  # add_prop({theme: "dark", locale: "en"})
102
129
  # @return [void]
103
130
  def add_prop(key_or_hash, data_value = nil)
131
+ @universal_renderer_props ||= {}
104
132
  if data_value.nil? && key_or_hash.is_a?(Hash)
105
133
  @universal_renderer_props.merge!(key_or_hash.deep_stringify_keys)
106
134
  else
@@ -126,6 +154,7 @@ module UniversalRenderer
126
154
  # push_prop(:item, "second") # @universal_renderer_props becomes { "item" => ["first", "second"] }
127
155
  # @return [void]
128
156
  def push_prop(key, value_to_add)
157
+ @universal_renderer_props ||= {}
129
158
  prop_key = key.to_s
130
159
  current_value = @universal_renderer_props[prop_key]
131
160
 
@@ -142,5 +171,18 @@ module UniversalRenderer
142
171
  @universal_renderer_props[prop_key] << value_to_add
143
172
  end
144
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
145
187
  end
146
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.4".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.4
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: []