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 +4 -4
- data/README.md +40 -48
- data/Rakefile +6 -3
- data/lib/generators/universal_renderer/install_generator.rb +10 -0
- data/lib/generators/universal_renderer/templates/initializer.rb +9 -1
- data/lib/universal_renderer/client/base.rb +18 -16
- data/lib/universal_renderer/client/http_pool.rb +114 -0
- data/lib/universal_renderer/client/stream/error_logger.rb +17 -11
- data/lib/universal_renderer/client/stream/execution.rb +37 -16
- data/lib/universal_renderer/client/stream/setup.rb +8 -11
- data/lib/universal_renderer/client/stream.rb +11 -7
- data/lib/universal_renderer/configuration.rb +24 -4
- data/lib/universal_renderer/renderable.rb +66 -17
- data/lib/universal_renderer/ssr/helpers.rb +24 -0
- data/lib/universal_renderer/version.rb +1 -1
- data/lib/universal_renderer.rb +34 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f0be3b52440e167e802130a84490b9c7a35f8d0d75ebbed0803f722edeec699b
|
|
4
|
+
data.tar.gz: 1c9b6213063855a08b0a3cc54771ad99c4679e2ea03f9c9382c181990bdc2db1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
"#{
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
17
|
+
begin
|
|
18
|
+
http_client.request(http_post_request) do |node_res, connection|
|
|
19
|
+
upstream_connection = connection
|
|
21
20
|
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
51
|
+
response.stream.close if success && !response.stream.closed?
|
|
31
52
|
end
|
|
32
53
|
|
|
33
|
-
|
|
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.
|
|
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.
|
|
12
|
-
if config.
|
|
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.
|
|
17
|
-
stream_uri = URI.join(parsed_ssr_url, config.
|
|
16
|
+
parsed_ssr_url = URI.parse(config.url)
|
|
17
|
+
stream_uri = URI.join(parsed_ssr_url, config.stream_path)
|
|
18
18
|
|
|
19
|
-
http =
|
|
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
|
-
|
|
26
|
-
|
|
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.
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
7
|
-
@timeout =
|
|
8
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
#
|
|
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
|
-
|
|
48
|
+
props
|
|
35
49
|
)
|
|
36
50
|
end
|
|
37
51
|
|
|
38
52
|
def ssr_streaming?
|
|
39
|
-
self.class.
|
|
53
|
+
self.class.ssr_streaming_preference
|
|
40
54
|
end
|
|
41
55
|
|
|
42
56
|
def render(*, **)
|
|
43
|
-
return super unless self.class.
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
"
|
|
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
|
data/lib/universal_renderer.rb
CHANGED
|
@@ -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
|
+
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:
|
|
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: []
|