universal_renderer 0.2.4

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 744a98174d956b133ed9cfb4cdaa9fe931dcc5f4ddd0797797aeb201ace4f387
4
+ data.tar.gz: 11ebab458200137d7ff996f11a2263854c2c62ef16ce8053f213045b96c374a5
5
+ SHA512:
6
+ metadata.gz: 9dd89b2e34c3c227ef792a4443c4eb6b93128542c3d409cafd5a78451e0863ae470636ef940062e77fdd423e18f03880d5e64214381c3b714fea99af5d7b1bc5
7
+ data.tar.gz: 99006bff1b47f731a57b58bc7700553c13a364977dd4a9bba7d72f034c1b9725821e375d69973d868c12ce3bccc1cb3e078d29d0314ef1548cac52c821d5f37e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright thaske
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,268 @@
1
+ # UniversalRenderer
2
+
3
+ UniversalRenderer provides a streamlined way to integrate Server-Side Rendering (SSR) with an external SSR service into your Rails application. It helps you forward rendering requests, manage static or streaming responses, and improve performance, SEO, and user experience for JavaScript-heavy frontends.
4
+
5
+ ## Features
6
+
7
+ - Supports both static and streaming SSR.
8
+ - Configurable SSR server endpoint and timeouts.
9
+ - Helper methods for passing data to your SSR service.
10
+ - Automatic fallback to client-side rendering if SSR fails.
11
+ - View helpers for integrating SSR content into your layouts.
12
+
13
+ ## Installation
14
+
15
+ 1. Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem "universal_renderer"
19
+ ```
20
+
21
+ 2. And then execute:
22
+
23
+ ```bash
24
+ $ bundle install
25
+ ```
26
+
27
+ 3. Run the install generator to create an initializer and include the necessary concern in your `ApplicationController`:
28
+
29
+ ```bash
30
+ $ rails generate universal_renderer:install
31
+ ```
32
+
33
+ This will:
34
+
35
+ - Create `config/initializers/universal_renderer.rb`.
36
+ - Add `include UniversalRenderer::Rendering` to your `app/controllers/application_controller.rb`.
37
+
38
+ ## Configuration
39
+
40
+ Configure UniversalRenderer in `config/initializers/universal_renderer.rb`:
41
+
42
+ ```ruby
43
+ UniversalRenderer.configure do |config|
44
+ # (Required) The base URL of your SSR server.
45
+ # Example: 'http://localhost:3001' or 'https://your-ssr-service.com'
46
+ # Can also be set via the SSR_SERVER_URL environment variable.
47
+ config.ssr_url = ENV.fetch("SSR_SERVER_URL", "http://localhost:3001")
48
+
49
+ # (Optional) Timeout in seconds for requests to the SSR server.
50
+ # Defaults to 3 seconds.
51
+ # Can also be set via the SSR_TIMEOUT environment variable.
52
+ config.timeout = (ENV["SSR_TIMEOUT"] || 3).to_i
53
+
54
+ # (Optional) The path on your SSR server for streaming requests.
55
+ # Defaults to '/stream'.
56
+ # Can also be set via the SSR_STREAM_PATH environment variable.
57
+ config.ssr_stream_path = ENV.fetch("SSR_STREAM_PATH", "/stream")
58
+ end
59
+ ```
60
+
61
+ **Environment Variables:**
62
+ The `ssr_url`, `timeout`, and `ssr_stream_path` can be configured directly via environment variables (`SSR_SERVER_URL`, `SSR_TIMEOUT`, `SSR_STREAM_PATH`). If set, environment variables will take precedence over values in the initializer.
63
+
64
+ ## Usage
65
+
66
+ The `universal_renderer:install` generator includes the `UniversalRenderer::Rendering` concern into your `ApplicationController`. This concern overrides `default_render` to automatically handle SSR.
67
+
68
+ ### Rendering Flow Details
69
+
70
+ This gem facilitates communication with an external Node.js SSR server (like one built with the `universal-renderer` npm package).
71
+
72
+ **1. Static Rendering Flow:**
73
+
74
+ - When streaming is disabled or not applicable, the gem makes a `POST` request to the SSR server's static endpoint (by default, the root `/` of the `config.ssr_url`).
75
+ - The request includes the current URL and any props added via `add_props`.
76
+ - The SSR server is expected to return a JSON object (e.g., `{ "html_content": "...", "meta_tags": "...", "initial_state": { ... } }`).
77
+ - This JSON response is made available in your Rails view (typically `app/views/ssr/index.html.erb`) as the `@ssr` instance variable (with symbolized keys, e.g., `@ssr[:html_content]`).
78
+
79
+ **2. Streaming Rendering Flow:**
80
+
81
+ - **Layout Rendering & Splitting**:
82
+ - Your Rails application (e.g., `app/views/layouts/application.html.erb`) begins to render. This layout **must** use the `<%= ssr_meta %>` helper at the point where SSR meta tags should be injected (usually late in the `<head>`) and `<%= ssr_body %>` where the main SSR application body should appear.
83
+ - The `Rendering` concern captures the full layout string. It then splits this string at the `<!-- SSR_META -->` comment (generated by `<%= ssr_meta %>`).
84
+ - The portion of the layout _before_ `<!-- SSR_META -->` is immediately written to the HTTP response stream and sent to the client.
85
+ - **Request to SSR Server**:
86
+ - The gem then makes a `POST` request to the SSR server's streaming endpoint (configured by `config.ssr_stream_path`, default `/stream`).
87
+ - The JSON payload of this request includes:
88
+ - `url`: The current Rails request URL.
89
+ - `props`: Data passed from your Rails controller using `add_props`.
90
+ - `template`: This field contains the latter part of the rendered Rails view string, starting _directly with_ the `<!-- SSR_META -->` marker (generated by `<%= ssr_meta %>`) and continuing to the end of the document. This string must also include the `<!-- SSR_BODY -->` marker (generated by `<%= ssr_body %>`). The Node.js SSR server uses this `template` to construct the response around the streamed React application: it first sends the `<!-- SSR_META -->` part from the `template`, then injects its computed meta tags, then sends the HTML content found between `<!-- SSR_META -->` and `<!-- SSR_BODY -->` from the `template`. After this, the React application is streamed (filling the place of `<!-- SSR_BODY -->`), and finally, the portion of the `template` that was after `<!-- SSR_BODY -->` is appended.
91
+ - **Streaming Response**:
92
+ - The Node.js SSR server streams HTML content back. This content is piped directly into the Rails response stream, filling the placeholders in the `template` it received.
93
+
94
+ **Fallback:**
95
+ If SSR (either static or streaming) fails in a way that allows for it, the system will typically fall back to rendering `app/views/application/index.html.erb`, which should contain your client-side rendering (CSR) entry point.
96
+
97
+ ### Passing Data to SSR (`add_props`)
98
+
99
+ In your controllers, you can use the `add_props` method to pass data from your Rails application to the SSR service. This data will be available as a JSON object under the `props` key in the JSON payload sent to your SSR service.
100
+
101
+ `add_props` can be called in two ways:
102
+
103
+ 1. **Key-Value Pair:**
104
+
105
+ ```ruby
106
+ # In your controller action
107
+ def show
108
+ @product = Product.find(params[:id])
109
+ add_props(:product, @product.as_json) # Ensure data is serializable
110
+ add_props(:current_user_name, current_user.name)
111
+ # ... default_render will be called implicitly
112
+ end
113
+ ```
114
+
115
+ This will result in the `props` object in the SSR request payload being like:
116
+ `{ "product": { ...product_data... }, "current_user_name": "User Name" }`
117
+
118
+ 2. **Hash Argument:**
119
+
120
+ ```ruby
121
+ # In your controller action
122
+ def index
123
+ @posts = Post.recent
124
+ add_props(posts: @posts.map(&:as_json), current_page: params[:page])
125
+ # ... default_render will be called implicitly
126
+ end
127
+ ```
128
+
129
+ This will result in the `props` object in the SSR request payload being like:
130
+ `{ "posts": [ ...posts_data... ], "current_page": "1" }`
131
+
132
+ Make sure any data passed is serializable to JSON (e.g., call `.as_json` on ActiveRecord objects).
133
+
134
+ ### Templates
135
+
136
+ The gem relies on a few conventional template paths:
137
+
138
+ - `app/views/ssr/index.html.erb`: Used when `StaticClient` successfully receives data from the SSR server. This template typically uses the data (available in `@ssr`) to render the page.
139
+ **Example (`app/views/ssr/index.html.erb` for static rendering):**
140
+ This example assumes your SSR server returns a JSON object with keys like `:meta`, `:styles`, `:root`, and `:state`. You would need to ensure proper sanitization of this content (e.g., using `sanitize` or a custom helper like `sanitize_ssr` shown below).
141
+
142
+ ```erb
143
+ <%# Assuming @ssr contains keys like :meta, :styles, :root, :state from the SSR server %>
144
+ <%# You are responsible for sanitizing these values appropriately. %>
145
+ <%# The sanitize_ssr helper is hypothetical and you'd need to implement it or use Rails' sanitize. %>
146
+
147
+ <% content_for :head_tags do %>
148
+ <%= raw @ssr[:meta] %> <%# Example: <meta name="description" content="..."> %>
149
+ <%= raw @ssr[:styles] %> <%# Example: <style>...</style> or <link rel="stylesheet" ...> %>
150
+ <% end %>
151
+
152
+ <div id="root" data-ssr-rendered="true">
153
+ <%= raw @ssr[:root] %> <%# Example: <div>Your pre-rendered React/Vue/etc. app</div> %>
154
+ </div>
155
+
156
+ <script id="ssr-state" type="application/json">
157
+ <%= raw @ssr[:state].to_json %>
158
+ </script>
159
+ ```
160
+
161
+ Remember to yield `:head_tags` in your main layout's `<head>` section (e.g., `<%= yield :head_tags %>`) if you use `content_for` as in this example.
162
+
163
+ - `app/views/application/index.html.erb`: This template is used as a fallback if SSR fails (e.g., SSR server is down, returns an error, or `StaticClient` receives no data). This usually contains your client-side rendering (CSR) entry point.
164
+
165
+ ### View Helpers (`SsrHelpers`)
166
+
167
+ The `UniversalRenderer::SsrHelpers` module provides helpers to mark locations in your HTML structure. For streaming to work correctly with a compatible Node.js SSR server (like `universal-renderer`), these helpers (or the raw HTML comments they produce) are crucial in your main Rails layout (e.g., `app/views/layouts/application.html.erb`).
168
+
169
+ - `ssr_meta`: Placeholder for meta tags or other head elements generated by SSR. When used in a Rails layout for streaming, this helper outputs `<!-- SSR_META -->`, which the Node.js SSR server uses as an injection point.
170
+ - `ssr_body`: Placeholder for the main root element where your SSR application will be rendered. When used in a Rails layout for streaming, this helper outputs `<!-- SSR_BODY -->`, which the Node.js SSR server uses to stream the main application content into.
171
+
172
+ **Example (`app/views/layouts/application.html.erb` for streaming):**
173
+
174
+ ```erb
175
+ <!DOCTYPE html>
176
+ <html>
177
+ <head>
178
+ <title>My App</title>
179
+ <%= csrf_meta_tags %>
180
+ <%= csp_meta_tag %>
181
+ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
182
+ <%= ssr_meta %> <%# SSR service might inject specific meta tags here %>
183
+ </head>
184
+ <body>
185
+ <div id="root">
186
+ <%= ssr_body %> <%# SSR service streams the main application content here %>
187
+ </div>
188
+ <%= javascript_importmap_tags %>
189
+ </body>
190
+ </html>
191
+ ```
192
+
193
+ ## SSR Server Expectations
194
+
195
+ Your external SSR server needs to meet the following expectations:
196
+
197
+ 1. **Static Rendering Endpoint (for `StaticClient`):**
198
+
199
+ - **Path:** Root path (`/`) of the `config.ssr_url`.
200
+ _Note: Some Node.js SSR servers like `universal-renderer` might default to a `/static` path. Ensure your SSR server listens on `/` for static requests if using this gem's default, or configure the Node.js server's `basePath` or routes accordingly._
201
+ - **Method:** `POST`
202
+ - **Request Body (JSON):**
203
+ ```jsonc
204
+ {
205
+ "url": "current_rails_request_original_url",
206
+ "props": {
207
+ // JSON object built from add_props calls
208
+ // e.g., "product": { ... }, "current_user_name": "..."
209
+ },
210
+ }
211
+ ```
212
+ - **Successful Response:** `200 OK` with a JSON body. The structure of this JSON is up to you, but it will be available in your `app/views/ssr/index.html.erb` template as `@ssr` (with keys symbolized). Example:
213
+ ```jsonc
214
+ {
215
+ "meta": "<meta name='description' content='Pre-rendered page description'>\n<meta property='og:title' content='My SSR Page'>",
216
+ "styles": "<link rel='stylesheet' href='/path/to/ssr-specific-styles.css'>",
217
+ "root": "<div><h1>Hello from SSR!</h1><p>This is your pre-rendered application content.</p></div>",
218
+ "state": {
219
+ "productId": 123,
220
+ "initialData": { "foo": "bar" },
221
+ },
222
+ }
223
+ ```
224
+
225
+ 2. **Streaming Rendering Endpoint (for `StreamClient`):**
226
+
227
+ - **Path:** `config.ssr_stream_path` (defaults to `/stream`) on the `config.ssr_url`.
228
+ - **Method:** `POST`
229
+ - **Request Body (JSON):**
230
+ ```jsonc
231
+ {
232
+ "url": "current_rails_request_original_url",
233
+ "props": {
234
+ // User-defined props from add_props calls,
235
+ // e.g., "product": { ... }, "current_user_name": "..."
236
+ },
237
+ "template": "<!-- SSR_META -->...HTML between meta and body...<!-- SSR_BODY -->...HTML after body...</html>",
238
+ }
239
+ ```
240
+ - **Successful Response:** `200 OK` with `Content-Type: text/html`. The body should be an HTML stream. The Node.js SSR server will use the `template` field from the request body to inject content at the `<!-- SSR_META -->` and `<!-- SSR_BODY -->` placeholders.
241
+
242
+ ## Example Controller
243
+
244
+ ```ruby
245
+ # app/controllers/products_controller.rb
246
+ class ProductsController < ApplicationController
247
+ def show
248
+ @product = Product.find(params[:id])
249
+
250
+ # Pass data to SSR service using add_props
251
+ add_props(
252
+ product: @product.as_json, # Ensure data is serializable
253
+ related_products: @product.related_products.limit(5).as_json,
254
+ )
255
+
256
+ # default_render (from UniversalRenderer::Rendering) will be called automatically,
257
+ # handling either streaming or static SSR based on configuration and environment.
258
+ end
259
+ end
260
+ ```
261
+
262
+ ## Contributing
263
+
264
+ TODO
265
+
266
+ ## License
267
+
268
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,132 @@
1
+ module UniversalRenderer
2
+ module Rendering
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ActionController::Live
7
+ helper UniversalRenderer::SsrHelpers
8
+ before_action :initialize_props
9
+ end
10
+
11
+ private
12
+
13
+ def default_render
14
+ return super unless request.format.html?
15
+ if use_ssr_streaming?
16
+ render_ssr_stream
17
+ else
18
+ @ssr =
19
+ UniversalRenderer::StaticClient.static(
20
+ request.original_url,
21
+ @universal_renderer_props
22
+ )
23
+ super
24
+ end
25
+ end
26
+
27
+ def render_ssr_stream
28
+ set_streaming_headers
29
+
30
+ full_layout = render_to_string
31
+
32
+ split_index = full_layout.index("<!-- SSR_META -->")
33
+ before_meta = full_layout[0...split_index]
34
+ after_meta = full_layout[split_index..]
35
+
36
+ response.stream.write(before_meta)
37
+
38
+ current_props = @universal_renderer_props.dup
39
+
40
+ streaming_succeeded =
41
+ UniversalRenderer::StreamClient.stream(
42
+ request.original_url,
43
+ current_props,
44
+ after_meta,
45
+ response
46
+ )
47
+
48
+ handle_ssr_stream_fallback(response) unless streaming_succeeded
49
+ end
50
+
51
+ def initialize_props
52
+ @universal_renderer_props = {}
53
+ end
54
+
55
+ def add_props(key_or_hash, data_value = nil)
56
+ if data_value.nil? && key_or_hash.is_a?(Hash)
57
+ @universal_renderer_props.merge!(key_or_hash.deep_stringify_keys)
58
+ else
59
+ @universal_renderer_props[key_or_hash.to_s] = data_value
60
+ end
61
+ end
62
+
63
+ # Allows a prop to be treated as an array, pushing new values to it.
64
+ # If the prop does not exist or is nil, it's initialized as an array.
65
+ # If the prop exists but is not an array (e.g., set as a scalar by `add_props`),
66
+ # its current value will be converted into the first element of the new array.
67
+ # If `value_to_add` is an array, its elements are concatenated. Otherwise, `value_to_add` is appended.
68
+ def push_prop(key, value_to_add)
69
+ prop_key = key.to_s
70
+ current_value = @universal_renderer_props[prop_key]
71
+
72
+ if current_value.nil?
73
+ @universal_renderer_props[prop_key] = []
74
+ elsif !current_value.is_a?(Array)
75
+ @universal_renderer_props[prop_key] = [current_value]
76
+ end
77
+ # At this point, @universal_renderer_props[prop_key] is guaranteed to be an array.
78
+
79
+ if value_to_add.is_a?(Array)
80
+ @universal_renderer_props[prop_key].concat(value_to_add)
81
+ else
82
+ @universal_renderer_props[prop_key] << value_to_add
83
+ end
84
+ end
85
+
86
+ def use_ssr_streaming?
87
+ %w[1 true yes y].include?(ENV["ENABLE_SSR_STREAMING"]&.downcase)
88
+ end
89
+
90
+ def set_streaming_headers
91
+ # Tell Cloudflare / proxies not to cache or buffer.
92
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
93
+ response.headers["Pragma"] = "no-cache"
94
+ response.headers["Expires"] = "0"
95
+
96
+ # Disable Nginx buffering per-response.
97
+ response.headers["X-Accel-Buffering"] = "no"
98
+ response.headers["Content-Type"] = "text/html"
99
+
100
+ # Remove Content-Length header to prevent buffering.
101
+ response.headers.delete("Content-Length")
102
+ end
103
+
104
+ def handle_ssr_stream_fallback(response)
105
+ # SSR streaming failed or was not possible (e.g. server down, config missing).
106
+ # Ensure response hasn't been touched in a way that prevents a new render.
107
+ return unless response.committed? || response.body.present?
108
+
109
+ Rails.logger.error(
110
+ "SSR stream fallback:" \
111
+ "Cannot render default fallback template because response was already committed or body present."
112
+ )
113
+ # Close the stream if it's still open to prevent client connection from hanging
114
+ # when we can't render a fallback page due to already committed response
115
+ response.stream.close unless response.stream.closed?
116
+ # If response not committed, no explicit render is called here,
117
+ # allowing Rails' default rendering behavior to take over.
118
+ end
119
+
120
+ # Overrides the built-in render_to_string.
121
+ # If you call render_to_string with no explicit template/partial/inline,
122
+ # it will fall back to 'ssr/index'.
123
+ def render_to_string(options = {}, *args, &block)
124
+ if options.is_a?(Hash) && !options.key?(:template) &&
125
+ !options.key?(:partial) && !options.key?(:inline) &&
126
+ !options.key?(:json) && !options.key?(:xml)
127
+ options = options.merge(template: "application/index")
128
+ end
129
+ super(options, *args, &block)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,19 @@
1
+ module UniversalRenderer
2
+ module SsrHelpers
3
+ def ssr_meta
4
+ "<!-- SSR_META -->".html_safe
5
+ end
6
+
7
+ def ssr_body
8
+ "<!-- SSR_BODY -->".html_safe
9
+ end
10
+
11
+ def sanitize_ssr(html)
12
+ sanitize(html, scrubber: SsrScrubber.new)
13
+ end
14
+
15
+ def use_ssr_streaming?
16
+ %w[1 true yes y].include?(ENV["ENABLE_SSR_STREAMING"]&.downcase)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json" # Ensure JSON is required for parsing and generation
5
+ require "uri" # Ensure URI is required for URI parsing
6
+
7
+ module UniversalRenderer
8
+ class StaticClient
9
+ class << self
10
+ # Performs a POST request to the SSR service to get statically rendered content.
11
+ #
12
+ # @param url [String] The URL of the page to render.
13
+ # @param props [Hash] Additional data to be passed for rendering (renamed from query_data for clarity).
14
+ # @return [Hash, nil] The parsed JSON response as symbolized keys if successful, otherwise nil.
15
+ def static(url, props)
16
+ ssr_url = UniversalRenderer.config.ssr_url
17
+ return if ssr_url.blank?
18
+
19
+ timeout = UniversalRenderer.config.timeout
20
+
21
+ begin
22
+ uri = URI.parse(ssr_url)
23
+ http = Net::HTTP.new(uri.host, uri.port)
24
+ http.use_ssl = (uri.scheme == "https")
25
+ http.open_timeout = timeout
26
+ http.read_timeout = timeout
27
+
28
+ request = Net::HTTP::Post.new(uri.request_uri) # Use uri.request_uri to include path if present in ssr_url
29
+ request.body = { url: url, props: props }.to_json
30
+ request["Content-Type"] = "application/json"
31
+
32
+ response = http.request(request)
33
+
34
+ if response.is_a?(Net::HTTPSuccess)
35
+ JSON.parse(response.body).deep_symbolize_keys
36
+ else
37
+ Rails.logger.error(
38
+ "SSR static request to #{ssr_url} failed: #{response.code} - #{response.message} (URL: #{url})"
39
+ )
40
+ nil
41
+ end
42
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
43
+ Rails.logger.error(
44
+ "SSR static request to #{ssr_url} timed out: #{e.class.name} - #{e.message} (URL: #{url})"
45
+ )
46
+ nil
47
+ rescue StandardError => e
48
+ Rails.logger.error(
49
+ "SSR static request to #{ssr_url} failed: #{e.class.name} - #{e.message} (URL: #{url})"
50
+ )
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ module UniversalRenderer
2
+ class StreamClient
3
+ module ErrorLogger
4
+ class << self
5
+ def _log_setup_error(error, target_uri_string)
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
+ )
11
+ end
12
+
13
+ def _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
+ )
17
+ end
18
+
19
+ def _log_unexpected_error(error, target_uri_string, context_message)
20
+ 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
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,88 @@
1
+ module UniversalRenderer
2
+ class StreamClient
3
+ module Execution
4
+ class << self
5
+ def _perform_streaming(
6
+ http_client,
7
+ http_post_request,
8
+ response,
9
+ stream_uri
10
+ )
11
+ http_client.request(http_post_request) do |node_res|
12
+ Execution._handle_node_response_streaming(
13
+ node_res,
14
+ response,
15
+ stream_uri
16
+ )
17
+
18
+ return true
19
+ end
20
+ false
21
+ end
22
+
23
+ def _handle_node_response_streaming(node_res, response, stream_uri)
24
+ if node_res.is_a?(Net::HTTPSuccess)
25
+ Execution._stream_response_body(node_res, response.stream)
26
+ else
27
+ Execution._handle_streaming_for_node_error(
28
+ node_res,
29
+ response,
30
+ stream_uri
31
+ )
32
+ end
33
+ rescue StandardError => e
34
+ Rails.logger.error(
35
+ "Error during SSR data transfer or stream writing from #{stream_uri}: #{e.class.name} - #{e.message}"
36
+ )
37
+
38
+ Execution._write_generic_html_error(
39
+ response.stream,
40
+ "Streaming Error",
41
+ "<p>A problem occurred while loading content. Please refresh.</p>"
42
+ )
43
+ ensure
44
+ response.stream.close unless response.stream.closed?
45
+ end
46
+
47
+ def _stream_response_body(source_http_response, target_io_stream)
48
+ source_http_response.read_body do |chunk|
49
+ target_io_stream.write(chunk)
50
+ end
51
+ end
52
+
53
+ def _handle_streaming_for_node_error(node_res, response, stream_uri)
54
+ Rails.logger.error(
55
+ "SSR stream server at #{stream_uri} responded with #{node_res.code} #{node_res.message}."
56
+ )
57
+
58
+ is_potentially_viewable_error =
59
+ node_res["Content-Type"]&.match?(%r{text/html}i)
60
+
61
+ if is_potentially_viewable_error
62
+ Rails.logger.info(
63
+ "Attempting to stream HTML error page from Node SSR server."
64
+ )
65
+ Execution._stream_response_body(node_res, response.stream)
66
+ else
67
+ Rails.logger.warn(
68
+ "Node SSR server error response Content-Type ('#{node_res["Content-Type"]}') is not text/html. " \
69
+ "Injecting generic error message into the stream."
70
+ )
71
+
72
+ Execution._write_generic_html_error(
73
+ response.stream,
74
+ "Application Error",
75
+ "<p>There was an issue rendering this page. Please try again later.</p>"
76
+ )
77
+ end
78
+ end
79
+
80
+ def _write_generic_html_error(stream, title_text, message_html_fragment)
81
+ return if stream.closed?
82
+
83
+ stream.write("<h1>#{title_text}</h1>#{message_html_fragment}")
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,37 @@
1
+ module UniversalRenderer
2
+ class StreamClient
3
+ module Setup
4
+ class << self
5
+ def _ensure_ssr_server_url_configured?(config)
6
+ config.ssr_url.present?
7
+ end
8
+
9
+ def _build_stream_request_components(body, config)
10
+ # 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
15
+
16
+ parsed_ssr_url = URI.parse(config.ssr_url)
17
+ stream_uri = URI.join(parsed_ssr_url, config.ssr_stream_path)
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
23
+
24
+ http_request =
25
+ Net::HTTP::Post.new(
26
+ stream_uri.request_uri,
27
+ "Content-Type" => "application/json"
28
+ )
29
+
30
+ http_request.body = body.to_json
31
+
32
+ [stream_uri, http, http_request]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stream_client/setup"
4
+ require_relative "stream_client/execution"
5
+ require_relative "stream_client/error_logger"
6
+
7
+ module UniversalRenderer
8
+ class StreamClient
9
+ extend Setup
10
+ extend Execution
11
+ extend ErrorLogger
12
+
13
+ # Orchestrates the streaming process for server-side rendering.
14
+ #
15
+ # @param url [String] The URL of the page to render.
16
+ # @param props [Hash] Data to be passed for rendering, including layout HTML.
17
+ # @param template [String] The HTML template to use for rendering.
18
+ # @param response [ActionDispatch::Response] The Rails response object to stream to.
19
+ # @return [Boolean] True if streaming was initiated, false otherwise.
20
+ def self.stream(url, props, template, response)
21
+ config = UniversalRenderer.config
22
+
23
+ unless Setup._ensure_ssr_server_url_configured?(config)
24
+ Rails.logger.warn(
25
+ "StreamClient: SSR URL (config.ssr_url) is not configured. Falling back."
26
+ )
27
+ return false
28
+ end
29
+
30
+ stream_uri_obj = nil
31
+ full_ssr_url_for_log = config.ssr_url.to_s # For logging in case of early error
32
+
33
+ begin
34
+ body = { url: url, props: props, template: template }
35
+
36
+ actual_stream_uri, http_client, http_post_request =
37
+ Setup._build_stream_request_components(body, config)
38
+
39
+ stream_uri_obj = actual_stream_uri
40
+
41
+ full_ssr_url_for_log = actual_stream_uri.to_s # Update for more specific logging
42
+ rescue URI::InvalidURIError => e
43
+ Rails.logger.error(
44
+ "StreamClient: SSR stream failed due to invalid URI ('#{config.ssr_url}'): #{e.message}"
45
+ )
46
+
47
+ return false
48
+ rescue StandardError => e
49
+ _log_setup_error(e, full_ssr_url_for_log)
50
+
51
+ return false
52
+ end
53
+
54
+ Execution._perform_streaming(
55
+ http_client,
56
+ http_post_request,
57
+ response,
58
+ stream_uri_obj
59
+ )
60
+ rescue Errno::ECONNREFUSED,
61
+ Errno::EHOSTUNREACH,
62
+ Net::OpenTimeout,
63
+ Net::ReadTimeout,
64
+ SocketError => e
65
+ uri_str_for_conn_error =
66
+ stream_uri_obj ? stream_uri_obj.to_s : full_ssr_url_for_log
67
+
68
+ ErrorLogger._log_connection_error(e, uri_str_for_conn_error)
69
+
70
+ false
71
+ rescue StandardError => e
72
+ uri_str_for_unexpected_error =
73
+ stream_uri_obj ? stream_uri_obj.to_s : full_ssr_url_for_log
74
+
75
+ ErrorLogger._log_unexpected_error(
76
+ e,
77
+ uri_str_for_unexpected_error,
78
+ "StreamClient: Unexpected error during SSR stream process"
79
+ )
80
+
81
+ false
82
+ end
83
+ end
84
+ end
data/config/routes.rb ADDED
@@ -0,0 +1 @@
1
+ Rails.application.routes.draw {}
@@ -0,0 +1,17 @@
1
+ module UniversalRenderer
2
+ class InstallGenerator < Rails::Generators::Base
3
+ source_root File.expand_path("templates", __dir__)
4
+
5
+ def copy_initializer
6
+ template "initializer.rb", "config/initializers/universal_renderer.rb"
7
+ end
8
+
9
+ def include_concern
10
+ application_controller = "app/controllers/application_controller.rb"
11
+
12
+ inject_into_class application_controller,
13
+ "ApplicationController",
14
+ " include UniversalRenderer::Rendering\n"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ UniversalRenderer.configure do |c|
2
+ c.ssr_url = ENV.fetch("SSR_SERVER_URL", "http://localhost:3001")
3
+ c.timeout = 3
4
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :universal_renderer do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,11 @@
1
+ module UniversalRenderer
2
+ class Configuration
3
+ attr_accessor :ssr_url, :timeout, :ssr_stream_path
4
+
5
+ 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")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ module UniversalRenderer
2
+ class Engine < ::Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,61 @@
1
+ require "loofah"
2
+
3
+ module UniversalRenderer
4
+ class SsrScrubber < ::Loofah::Scrubber
5
+ def initialize
6
+ super
7
+ @direction = :top_down
8
+ end
9
+
10
+ def scrub(node)
11
+ # Primary actions: stop if script, continue if a passthrough tag, otherwise clean attributes.
12
+ return Loofah::Scrubber::STOP if handle_script_node(node) # Checks for <script> and removes it
13
+
14
+ # Allows <link rel="stylesheet">, <style>, <meta> to pass through this scrubber.
15
+ return Loofah::Scrubber::CONTINUE if passthrough_node?(node)
16
+
17
+ # For all other nodes, clean potentially harmful attributes.
18
+ clean_attributes(node)
19
+ # Default Loofah behavior (CONTINUE for children) applies if not returned earlier.
20
+ end
21
+
22
+ private
23
+
24
+ # Handles <script> tags: removes them and returns true if a script node was processed.
25
+ def handle_script_node(node)
26
+ return false unless node.name == "script"
27
+
28
+ node.remove
29
+ true # Indicates the node was a script and has been handled.
30
+ end
31
+
32
+ # Checks if the node is a type that should bypass detailed attribute scrubbing.
33
+ def passthrough_node?(node)
34
+ (node.name == "link" && node["rel"]&.to_s&.downcase == "stylesheet") ||
35
+ %w[style meta].include?(node.name)
36
+ end
37
+
38
+ # Orchestrates the cleaning of attributes for a given node.
39
+ def clean_attributes(node)
40
+ remove_javascript_href(node)
41
+ remove_event_handlers(node)
42
+ end
43
+
44
+ # Removes "javascript:" hrefs from <a> tags.
45
+ def remove_javascript_href(node)
46
+ if node.name == "a" &&
47
+ node["href"]&.to_s&.downcase&.start_with?("javascript:")
48
+ node.remove_attribute("href")
49
+ end
50
+ end
51
+
52
+ # Removes "on*" event handler attributes from any node.
53
+ def remove_event_handlers(node)
54
+ attrs_to_remove =
55
+ node.attributes.keys.select do |name|
56
+ name.to_s.downcase.start_with?("on")
57
+ end
58
+ attrs_to_remove.each { |attr_name| node.remove_attribute(attr_name) }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,3 @@
1
+ module UniversalRenderer
2
+ VERSION = "0.2.4".freeze
3
+ end
@@ -0,0 +1,18 @@
1
+ require "universal_renderer/version"
2
+ require "universal_renderer/engine"
3
+ require "universal_renderer/configuration"
4
+ require "universal_renderer/ssr_scrubber"
5
+
6
+ module UniversalRenderer
7
+ class << self
8
+ attr_writer :config
9
+
10
+ def config
11
+ @config ||= Configuration.new
12
+ end
13
+
14
+ def configure
15
+ yield(config)
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: universal_renderer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.4
5
+ platform: ruby
6
+ authors:
7
+ - thaske
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-05-20 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: loofah
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.24'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.24'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 7.1.5.1
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '7.1'
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 7.1.5.1
46
+ description: Provides helper methods and configuration to forward rendering requests
47
+ from a Rails app to an external SSR server and return the response.
48
+ email:
49
+ - 10328778+thaske@users.noreply.github.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - MIT-LICENSE
55
+ - README.md
56
+ - Rakefile
57
+ - app/controllers/concerns/universal_renderer/rendering.rb
58
+ - app/helpers/universal_renderer/ssr_helpers.rb
59
+ - app/services/universal_renderer/static_client.rb
60
+ - app/services/universal_renderer/stream_client.rb
61
+ - app/services/universal_renderer/stream_client/error_logger.rb
62
+ - app/services/universal_renderer/stream_client/execution.rb
63
+ - app/services/universal_renderer/stream_client/setup.rb
64
+ - config/routes.rb
65
+ - lib/generators/universal_renderer/install_generator.rb
66
+ - lib/generators/universal_renderer/templates/initializer.rb
67
+ - lib/tasks/universal_renderer_tasks.rake
68
+ - lib/universal_renderer.rb
69
+ - lib/universal_renderer/configuration.rb
70
+ - lib/universal_renderer/engine.rb
71
+ - lib/universal_renderer/ssr_scrubber.rb
72
+ - lib/universal_renderer/version.rb
73
+ homepage: https://github.com/thaske/universal_renderer
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ allowed_push_host: https://rubygems.org
78
+ homepage_uri: https://github.com/thaske/universal_renderer
79
+ changelog_uri: https://github.com/thaske/universal_renderer/blob/main/CHANGELOG.md
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.0.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.6.2
95
+ specification_version: 4
96
+ summary: Facilitates Server-Side Rendering (SSR) in Rails applications.
97
+ test_files: []