universal_renderer 0.4.0 → 0.4.2

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: a84ac507fd401a10c8d36c9b7600d02b4403d84bdcbd63a0c4ac1f707efe0a7f
4
- data.tar.gz: deac3f050553555b313864a1eb06ea69ff8e8d25ed78c47a70cb0c54e562012e
3
+ metadata.gz: a5fa544d66ff988bec74e5bb3219bc9fef83bfd4b4049559478134b138c73ade
4
+ data.tar.gz: 159dfc977add1ede83e0dc3e3c37332556c1e0b0b127a0cc8a47923f676e1e49
5
5
  SHA512:
6
- metadata.gz: cda8091a0ad83367e3a0e8bcb93d560ce4b9e778cc543b7edba4084d0e7a540eda82fcd952e40b00115fef62326d15f760de905d882c607224047565d58c9c96
7
- data.tar.gz: 15a2798e31d87ca6eec64e0613fedf2da515db85f3b798a792b0917d1719e970862c476c51eaebf65d8550dc0885bd5619edf662819085e3a132d877a4bdf73b
6
+ metadata.gz: fafcf155308586b524e8b2a158add8cdc4b80a012c9cdcf9d35d38c2e2d5d9f204c99407430a7dfb65143a7f4f2d8838788a3e7d6741c9140688d6175baf3b73
7
+ data.tar.gz: 8f952d4175fcf095e2aeb04fcf3e2d6d67a804e86613ad2a2a605623eef62e14249de60d639a1019396fa50b4761609bb67cc6b5efd86eec6508eba938278d64
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # UniversalRenderer
2
2
 
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/thaske/universal_renderer/ci.yml)](https://github.com/thaske/universal_renderer/actions/workflows/ci.yml)
4
+
3
5
  [![Gem Version](https://img.shields.io/gem/v/universal_renderer)](https://rubygems.org/gems/universal_renderer) [![NPM Version](https://img.shields.io/npm/v/universal-renderer)](https://www.npmjs.com/package/universal-renderer)
4
6
 
5
7
  A streamlined solution for integrating Server-Side Rendering (SSR) into Rails applications.
@@ -16,6 +18,16 @@ UniversalRenderer helps you forward rendering requests to external SSR services,
16
18
  - **Automatic fallback** to client-side rendering if SSR fails
17
19
  - **View helpers** for easy integration into your layouts
18
20
 
21
+ ## Requirements
22
+
23
+ > **Heads-up ⚠️**  The JavaScript side of UniversalRenderer is **Bun-native**.
24
+ >
25
+ > • You **must** run the SSR server with **Bun ≥ 1.2**.
26
+ >
27
+ > • The exported helpers call Bun's built-in HTTP router and `Response` implementation; they **will not boot under Node, Deno, or Cloudflare Workers**.
28
+ >
29
+ > • The Ruby gem is runtime-agnostic and continues to work on every platform – only the SSR service requires Bun.
30
+
19
31
  ## Installation
20
32
 
21
33
  1. Add to your Gemfile:
@@ -65,8 +77,9 @@ class ProductsController < ApplicationController
65
77
 
66
78
  fetch_ssr # or fetch on demand
67
79
 
68
- # @ssr will now contain the SSR response, where the symbolized keys
69
- # are the same keys returned by the SSR server response.
80
+ # @ssr will now contain a UniversalRenderer::SSR::Response which exposes
81
+ # `.head`, `.body` and optional `.body_attrs` values returned by the SSR
82
+ # service.
70
83
  end
71
84
 
72
85
  def default_render
@@ -80,22 +93,17 @@ end
80
93
  ```erb
81
94
  <%# "ssr/index" %>
82
95
 
83
- <%# Now you can use the instance variable @ssr in your layout. %>
84
- <%# We'll send it with keys :meta, :styles, :root, and :state below. %>
85
- <%# We can use the provided sanitize_ssr helper to sanitize our content %>
96
+ <%# Inject SSR snippets using the provided helpers. When streaming is enabled
97
+ these render HTML placeholders (<!-- SSR_HEAD --> / <!-- SSR_BODY -->);
98
+ otherwise they output the sanitised HTML returned by the SSR service. %>
86
99
 
87
- <% content_for :meta do %>
88
- <%= sanitize_ssr @ssr[:meta] %>
89
- <% end %>
100
+ <head>
101
+ <%= ssr_head %>
102
+ </head>
90
103
 
91
104
  <div id="root">
92
- <%= sanitize_ssr @ssr[:styles] %>
93
- <%= sanitize_ssr @ssr[:root] %>
105
+ <%= ssr_body %>
94
106
  </div>
95
-
96
- <script id="state" type="application/json">
97
- <%= json_escape(@ssr[:state].to_json) %>
98
- </script>
99
107
  ```
100
108
 
101
109
  ## Setting Up the SSR Server
@@ -119,7 +127,7 @@ To set up the SSR server for your Rails application:
119
127
  HelmetProvider,
120
128
  type HelmetDataContext,
121
129
  } from "@dr.pogodin/react-helmet";
122
- import { dehydrate, QueryClient, QueryClientProvider } from "react-query";
130
+ import { QueryClient, QueryClientProvider } from "react-query";
123
131
  import { StaticRouter } from "react-router";
124
132
  import { ServerStyleSheet } from "styled-components";
125
133
 
@@ -133,7 +141,7 @@ To set up the SSR server for your Rails application:
133
141
  const sheet = new ServerStyleSheet();
134
142
  const queryClient = new QueryClient();
135
143
 
136
- const { query_data } = props;
144
+ const { query_data = [] } = props;
137
145
  query_data.forEach(({ key, data }) => queryClient.setQueryData(key, data));
138
146
  const state = dehydrate(queryClient);
139
147
 
@@ -145,29 +153,32 @@ To set up the SSR server for your Rails application:
145
153
  <App />
146
154
  </StaticRouter>
147
155
  </QueryClientProvider>
156
+ <template id="state" data-state={JSON.stringify(state)} />
148
157
  </HelmetProvider>,
149
158
  );
150
159
 
151
- return { jsx, helmetContext, sheet, state, queryClient };
160
+ return { jsx, helmetContext, sheet, queryClient };
152
161
  }
153
162
  ```
154
163
 
155
- 3. Update your `application.tsx` to hydrate the SSR state:
164
+ 3. Update your `application.tsx` to hydrate on the client:
156
165
 
157
166
  ```tsx
158
167
  import { HelmetProvider } from "@dr.pogodin/react-helmet";
159
- import { createRoot, hydrateRoot } from "react-dom/client";
160
- import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
168
+ import { hydrateRoot } from "react-dom/client";
161
169
  import { BrowserRouter } from "react-router";
162
-
170
+ import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
163
171
  import App from "@/App";
164
172
  import Metadata from "@/components/Metadata";
165
173
 
166
174
  const queryClient = new QueryClient();
167
- const stateElement = document.getElementById("state")!;
168
- const state = JSON.parse(stateElement.textContent);
169
175
 
170
- const app = (
176
+ const stateEl = document.getElementById("state");
177
+ const state = JSON.parse(stateEl?.dataset.state ?? "{}");
178
+ stateEl?.remove();
179
+
180
+ hydrateRoot(
181
+ document.getElementById("root")!,
171
182
  <HelmetProvider>
172
183
  <Metadata url={window.location.href} />
173
184
  <QueryClientProvider client={queryClient}>
@@ -177,58 +188,40 @@ To set up the SSR server for your Rails application:
177
188
  </BrowserRouter>
178
189
  </Hydrate>
179
190
  </QueryClientProvider>
180
- </HelmetProvider>
191
+ </HelmetProvider>,
181
192
  );
182
-
183
- const rootElement = document.getElementById("root")!;
184
- hydrateRoot(rootElement, app);
185
193
  ```
186
194
 
187
195
  4. Create an SSR entry point at `app/frontend/ssr/ssr.ts`:
188
196
 
189
197
  ```ts
198
+ import { head, transform } from "@/ssr/utils";
190
199
  import { renderToString } from "react-dom/server.node";
191
- import { createSsrServer } from "universal-renderer";
192
- import setup from "@/ssr/setup";
193
- import {
194
- createRenderStreamTransformer,
195
- extractMeta,
196
- getRequestLogger,
197
- getStateElement,
198
- } from "@/ssr/utils";
199
-
200
- const app = await createSsrServer({
201
- middleware: (app) => {
202
- app.use(getRequestLogger());
203
- },
204
- callbacks: {
205
- setup: setup,
206
- render: async ({ jsx, helmetContext, sheet, state }) => {
207
- const root = renderToString(jsx);
208
- const meta = extractMeta(helmetContext);
209
- const styles = sheet.getStyleTags();
210
- return { meta, root, styles, state };
211
- },
212
- cleanup: async ({ sheet, queryClient }) => {
213
- sheet?.seal();
214
- queryClient?.clear();
215
- },
216
- error: (error, _, errorContext) => {
217
- console.error(`[SSR] ${errorContext} error:`, error);
218
- },
219
- },
220
- streamCallbacks: {
221
- getReactNode: async ({ jsx }) => jsx,
222
- getMetaTags: async ({ helmetContext }) => extractMeta(helmetContext),
223
- createRenderStreamTransformer,
224
- onBeforeWriteClosingHtml: async (res, { state }) => {
225
- res.write(getStateElement(state) + "\n");
226
- },
227
- },
200
+ import { createServer } from "universal-renderer";
201
+ import { createServer as createViteServer } from "vite";
202
+
203
+ const vite = await createViteServer({
204
+ server: { middlewareMode: true },
205
+ appType: "custom",
228
206
  });
229
207
 
230
- app.listen(3001, () => {
231
- console.log(`[SSR] server started on http://localhost:3001`);
208
+ await createServer({
209
+ port: 3001,
210
+ middleware: vite.middlewares,
211
+
212
+ setup: (await import("@/ssr/setup")).default,
213
+ render: ({ app, helmet, sheet }) => {
214
+ const root = renderToString(app);
215
+ const styles = sheet.getStyleTags();
216
+ return {
217
+ head: head({ helmet }),
218
+ body: `${root}\n${styles}`,
219
+ };
220
+ },
221
+ cleanup: ({ sheet, queryClient }) => {
222
+ sheet?.seal();
223
+ queryClient?.clear();
224
+ },
232
225
  });
233
226
  ```
234
227
 
@@ -11,63 +11,64 @@ module UniversalRenderer
11
11
  # is needed in its entirety before the Rails view rendering proceeds,
12
12
  # as opposed to streaming SSR.
13
13
  class Base
14
- class << self
15
- # Performs a POST request to the SSR service to retrieve the complete SSR content.
16
- # This is used for non-streaming SSR, where the entire payload is fetched
17
- # before the main application view is rendered.
18
- #
19
- # @param url [String] The URL of the page to render on the SSR server.
20
- # This should typically be the `request.original_url` from the controller.
21
- # @param props [Hash] A hash of props to be passed to the SSR service.
22
- # These props will be available to the frontend components for rendering.
23
- # @return [Hash, nil] The parsed JSON response from the SSR service with symbolized keys
24
- # if the request is successful (HTTP 2xx). The structure of the hash depends
25
- # on the SSR service implementation but typically includes keys like `:head`,
26
- # `:body_html`, and `:body_attrs`.
27
- # Returns `nil` if:
28
- # - The `ssr_url` is not configured.
29
- # - The request times out (open or read).
30
- # - The SSR service returns a non-successful HTTP status code.
31
- # - Any other `StandardError` occurs during the request.
32
- # In case of failures, an error message is logged to `Rails.logger`.
33
- def fetch(url, props)
34
- ssr_url = UniversalRenderer.config.ssr_url
35
- return if ssr_url.blank?
14
+ # Performs a POST request to the SSR service to retrieve the complete SSR content.
15
+ # This is used for non-streaming SSR, where the entire payload is fetched
16
+ # before the main application view is rendered.
17
+ #
18
+ # @param url [String] The URL of the page to render on the SSR server.
19
+ # This should typically be the `request.original_url` from the controller.
20
+ # @param props [Hash] A hash of props to be passed to the SSR service.
21
+ # These props will be available to the frontend components for rendering.
22
+ # @return [UniversalRenderer::SSR::Response, nil] The SSR payload wrapped in
23
+ # a {UniversalRenderer::SSR::Response} struct when the request is successful
24
+ # (HTTP 2xx). Returns `nil` when the request fails or the SSR service is
25
+ # unreachable.
26
+ def self.call(url, props)
27
+ ssr_url = UniversalRenderer.config.ssr_url
28
+ return if ssr_url.blank?
36
29
 
37
- timeout = UniversalRenderer.config.timeout
30
+ timeout = UniversalRenderer.config.timeout
38
31
 
39
- begin
40
- uri = URI.parse(ssr_url)
41
- http = Net::HTTP.new(uri.host, uri.port)
42
- http.use_ssl = (uri.scheme == "https")
43
- http.open_timeout = timeout
44
- http.read_timeout = timeout
32
+ begin
33
+ 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
45
38
 
46
- request = Net::HTTP::Post.new(uri.request_uri)
47
- request.body = { url: url, props: props }.to_json
48
- request["Content-Type"] = "application/json"
39
+ request = Net::HTTP::Post.new(uri.request_uri)
40
+ request.body = { url: url, props: props }.to_json
41
+ request["Content-Type"] = "application/json"
49
42
 
50
- response = http.request(request)
43
+ response = http.request(request)
51
44
 
52
- if response.is_a?(Net::HTTPSuccess)
53
- JSON.parse(response.body).deep_symbolize_keys
54
- else
55
- Rails.logger.error(
56
- "SSR fetch request to #{ssr_url} failed: #{response.code} - #{response.message} (URL: #{url})"
57
- )
58
- nil
59
- end
60
- rescue Net::OpenTimeout, Net::ReadTimeout => e
61
- Rails.logger.error(
62
- "SSR fetch request to #{ssr_url} timed out: #{e.class.name} - #{e.message} (URL: #{url})"
45
+ if response.is_a?(Net::HTTPSuccess)
46
+ raw_data = JSON.parse(response.body).deep_symbolize_keys
47
+
48
+ # Map the keys we care about to the Struct. The Node service might
49
+ # send `:body_html` instead of `:body`; favour the latter if
50
+ # present but fall back gracefully.
51
+ UniversalRenderer::SSR::Response.new(
52
+ head: raw_data[:head],
53
+ body: raw_data[:body] || raw_data[:body_html],
54
+ body_attrs: raw_data[:body_attrs]
63
55
  )
64
- nil
65
- rescue StandardError => e
56
+ else
66
57
  Rails.logger.error(
67
- "SSR fetch request to #{ssr_url} failed: #{e.class.name} - #{e.message} (URL: #{url})"
58
+ "SSR fetch request to #{ssr_url} failed: #{response.code} - #{response.message} (URL: #{url})"
68
59
  )
69
60
  nil
70
61
  end
62
+ 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
+ )
66
+ nil
67
+ rescue StandardError => e
68
+ Rails.logger.error(
69
+ "SSR fetch request to #{ssr_url} failed: #{e.class.name} - #{e.message} (URL: #{url})"
70
+ )
71
+ nil
71
72
  end
72
73
  end
73
74
  end
@@ -2,28 +2,26 @@ module UniversalRenderer
2
2
  module Client
3
3
  class Stream
4
4
  module ErrorLogger
5
- class << self
6
- def _log_setup_error(error, target_uri_string)
7
- backtrace_info = error.backtrace&.first || "No backtrace available"
8
- Rails.logger.error(
9
- "Unexpected error during SSR stream setup for #{target_uri_string}: " \
10
- "#{error.class.name} - #{error.message} at #{backtrace_info}"
11
- )
12
- end
5
+ def self.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
13
12
 
14
- def _log_connection_error(error, target_uri_string)
15
- Rails.logger.error(
16
- "SSR stream connection to #{target_uri_string} failed: #{error.class.name} - #{error.message}"
17
- )
18
- end
13
+ 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
+ )
17
+ end
19
18
 
20
- def _log_unexpected_error(error, target_uri_string, context_message)
21
- backtrace_info = error.backtrace&.first || "No backtrace available"
22
- Rails.logger.error(
23
- "#{context_message} for #{target_uri_string}: " \
24
- "#{error.class.name} - #{error.message} at #{backtrace_info}"
25
- )
26
- end
19
+ def self.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
+ )
27
25
  end
28
26
  end
29
27
  end
@@ -2,91 +2,35 @@ module UniversalRenderer
2
2
  module Client
3
3
  class Stream
4
4
  module Execution
5
- class << self
6
- def _perform_streaming(
7
- http_client,
8
- http_post_request,
9
- response,
10
- stream_uri
11
- )
12
- http_client.request(http_post_request) do |node_res|
13
- Execution._handle_node_response_streaming(
14
- node_res,
15
- response,
16
- stream_uri,
17
- )
18
-
19
- return true
20
- end
21
- false
22
- end
23
-
24
- def _handle_node_response_streaming(node_res, response, stream_uri)
5
+ def self.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|
25
12
  if node_res.is_a?(Net::HTTPSuccess)
26
- Execution._stream_response_body(node_res, response.stream)
13
+ node_res.read_body { |chunk| response.stream.write(chunk) }
27
14
  else
28
- Execution._handle_streaming_for_node_error(
29
- node_res,
30
- response,
31
- stream_uri,
15
+ Rails.logger.error(
16
+ "SSR stream server at #{stream_uri} responded with #{node_res.code} #{node_res.message}."
32
17
  )
18
+
19
+ # Close stream without forwarding error to allow fallback to client rendering
20
+ response.stream.close unless response.stream.closed?
21
+
22
+ false
33
23
  end
34
24
  rescue StandardError => e
35
25
  Rails.logger.error(
36
- "Error during SSR data transfer or stream writing from #{stream_uri}: #{e.class.name} - #{e.message}",
37
- )
38
-
39
- Execution._write_generic_html_error(
40
- response.stream,
41
- "Streaming Error",
42
- "<p>A problem occurred while loading content. Please refresh.</p>",
26
+ "Error during SSR data transfer or stream writing from #{stream_uri}: #{e.class.name} - #{e.message}"
43
27
  )
28
+ false
44
29
  ensure
45
30
  response.stream.close unless response.stream.closed?
46
31
  end
47
32
 
48
- def _stream_response_body(source_http_response, target_io_stream)
49
- source_http_response.read_body do |chunk|
50
- target_io_stream.write(chunk)
51
- end
52
- end
53
-
54
- def _handle_streaming_for_node_error(node_res, response, stream_uri)
55
- Rails.logger.error(
56
- "SSR stream server at #{stream_uri} responded with #{node_res.code} #{node_res.message}.",
57
- )
58
-
59
- is_potentially_viewable_error =
60
- node_res["Content-Type"]&.match?(%r{text/html}i)
61
-
62
- if is_potentially_viewable_error
63
- Rails.logger.info(
64
- "Attempting to stream HTML error page from Node SSR server.",
65
- )
66
- Execution._stream_response_body(node_res, response.stream)
67
- else
68
- Rails.logger.warn(
69
- "Node SSR server error response Content-Type ('#{node_res["Content-Type"]}') is not text/html. " \
70
- "Injecting generic error message into the stream.",
71
- )
72
-
73
- Execution._write_generic_html_error(
74
- response.stream,
75
- "Application Error",
76
- "<p>There was an issue rendering this page. Please try again later.</p>",
77
- )
78
- end
79
- end
80
-
81
- def _write_generic_html_error(
82
- stream,
83
- title_text,
84
- message_html_fragment
85
- )
86
- return if stream.closed?
87
-
88
- stream.write("<h1>#{title_text}</h1>#{message_html_fragment}")
89
- end
33
+ true
90
34
  end
91
35
  end
92
36
  end
@@ -2,36 +2,34 @@ module UniversalRenderer
2
2
  module Client
3
3
  class Stream
4
4
  module Setup
5
- class << self
6
- def _ensure_ssr_server_url_configured?(config)
7
- config.ssr_url.present?
8
- end
5
+ def self.ensure_ssr_server_url_configured?(config)
6
+ config.ssr_url.present?
7
+ end
9
8
 
10
- def _build_stream_request_components(body, config)
11
- # Ensure ssr_url is present, though _ensure_ssr_server_url_configured? should have caught this.
12
- # However, direct calls to this method might occur, so a check or reliance on config.ssr_url is important.
13
- if config.ssr_url.blank?
14
- raise ArgumentError, "SSR URL is not configured."
15
- end
9
+ def self.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
16
15
 
17
- parsed_ssr_url = URI.parse(config.ssr_url)
18
- stream_uri = URI.join(parsed_ssr_url, config.ssr_stream_path)
16
+ parsed_ssr_url = URI.parse(config.ssr_url)
17
+ stream_uri = URI.join(parsed_ssr_url, config.ssr_stream_path)
19
18
 
20
- http = Net::HTTP.new(stream_uri.host, stream_uri.port)
21
- http.use_ssl = (stream_uri.scheme == "https")
22
- http.open_timeout = config.timeout
23
- http.read_timeout = config.timeout
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
24
23
 
25
- http_request =
26
- Net::HTTP::Post.new(
27
- stream_uri.request_uri,
28
- "Content-Type" => "application/json"
29
- )
24
+ http_request =
25
+ Net::HTTP::Post.new(
26
+ stream_uri.request_uri,
27
+ "Content-Type" => "application/json"
28
+ )
30
29
 
31
- http_request.body = body.to_json
30
+ http_request.body = body.to_json
32
31
 
33
- [stream_uri, http, http_request]
34
- end
32
+ [stream_uri, http, http_request]
35
33
  end
36
34
  end
37
35
  end
@@ -18,10 +18,10 @@ module UniversalRenderer
18
18
  # @param template [String] The HTML template to use for rendering.
19
19
  # @param response [ActionDispatch::Response] The Rails response object to stream to.
20
20
  # @return [Boolean] True if streaming was initiated, false otherwise.
21
- def self.stream(url, props, template, response)
21
+ def self.call(url, props, template, response)
22
22
  config = UniversalRenderer.config
23
23
 
24
- unless Setup._ensure_ssr_server_url_configured?(config)
24
+ unless Setup.ensure_ssr_server_url_configured?(config)
25
25
  Rails.logger.warn(
26
26
  "Stream: SSR URL (config.ssr_url) is not configured. Falling back."
27
27
  )
@@ -35,7 +35,7 @@ module UniversalRenderer
35
35
  body = { url: url, props: props, template: template }
36
36
 
37
37
  actual_stream_uri, http_client, http_post_request =
38
- Setup._build_stream_request_components(body, config)
38
+ Setup.build_stream_request_components(body, config)
39
39
 
40
40
  stream_uri_obj = actual_stream_uri
41
41
 
@@ -44,15 +44,13 @@ module UniversalRenderer
44
44
  Rails.logger.error(
45
45
  "Stream: SSR stream failed due to invalid URI ('#{config.ssr_url}'): #{e.message}"
46
46
  )
47
-
48
47
  return false
49
48
  rescue StandardError => e
50
- _log_setup_error(e, full_ssr_url_for_log)
51
-
49
+ log_setup_error(e, full_ssr_url_for_log)
52
50
  return false
53
51
  end
54
52
 
55
- Execution._perform_streaming(
53
+ Execution.perform_streaming(
56
54
  http_client,
57
55
  http_post_request,
58
56
  response,
@@ -67,14 +65,14 @@ module UniversalRenderer
67
65
  uri_str_for_conn_error =
68
66
  stream_uri_obj ? stream_uri_obj.to_s : full_ssr_url_for_log
69
67
 
70
- ErrorLogger._log_connection_error(e, uri_str_for_conn_error)
68
+ ErrorLogger.log_connection_error(e, uri_str_for_conn_error)
71
69
 
72
70
  false
73
71
  rescue StandardError => e
74
72
  uri_str_for_unexpected_error =
75
73
  stream_uri_obj ? stream_uri_obj.to_s : full_ssr_url_for_log
76
74
 
77
- ErrorLogger._log_unexpected_error(
75
+ ErrorLogger.log_unexpected_error(
78
76
  e,
79
77
  uri_str_for_unexpected_error,
80
78
  "Stream: Unexpected error during SSR stream process"
@@ -29,23 +29,22 @@ module UniversalRenderer
29
29
  # or `nil` if the fetch fails or SSR is not configured.
30
30
  def fetch_ssr
31
31
  @ssr =
32
- UniversalRenderer::Client::Base.fetch(
32
+ UniversalRenderer::Client::Base.call(
33
33
  request.original_url,
34
- @universal_renderer_props,
34
+ @universal_renderer_props
35
35
  )
36
36
  end
37
37
 
38
- def use_ssr_streaming?
38
+ def ssr_streaming?
39
39
  self.class.try(:ssr_streaming_preference)
40
40
  end
41
41
 
42
- def render(*args, **kwargs)
42
+ def render(*, **)
43
43
  return super unless self.class.enable_ssr
44
-
45
44
  return super unless request.format.html?
46
45
 
47
- if use_ssr_streaming?
48
- success = render_ssr_stream(*args, **kwargs)
46
+ if ssr_streaming?
47
+ success = render_ssr_stream(*, **)
49
48
  super unless success
50
49
  else
51
50
  fetch_ssr
@@ -55,16 +54,16 @@ module UniversalRenderer
55
54
 
56
55
  private
57
56
 
58
- def render_ssr_stream(*args, **kwargs)
59
- full_layout = render_to_string(*args, **kwargs)
57
+ def render_ssr_stream(*, **)
58
+ full_layout = render_to_string(*, **)
60
59
  current_props = @universal_renderer_props.dup
61
60
 
62
61
  streaming_succeeded =
63
- UniversalRenderer::Client::Stream.stream(
62
+ UniversalRenderer::Client::Stream.call(
64
63
  request.original_url,
65
64
  current_props,
66
65
  full_layout,
67
- response,
66
+ response
68
67
  )
69
68
 
70
69
  # SSR streaming failed or was not possible (e.g. server down, config missing).
@@ -73,7 +72,7 @@ module UniversalRenderer
73
72
  else
74
73
  Rails.logger.error(
75
74
  "SSR stream fallback: " \
76
- "Streaming failed, proceeding with standard rendering.",
75
+ "Streaming failed, proceeding with standard rendering."
77
76
  )
78
77
  false
79
78
  end
@@ -1,12 +1,18 @@
1
1
  module UniversalRenderer
2
2
  module SSR
3
3
  module Helpers
4
- # @!method ssr_meta
5
- # Outputs a meta tag placeholder for SSR content.
4
+ # @!method ssr_head
5
+ # Outputs a head placeholder for SSR content.
6
6
  # This placeholder is used by the rendering process to inject SSR metadata.
7
- # @return [String] The HTML-safe string "<!-- SSR_META -->".
8
- def ssr_meta
9
- Placeholders::META.html_safe
7
+ # @return [String] The HTML-safe string "<!-- SSR_HEAD -->".
8
+ def ssr_head
9
+ if ssr_streaming?
10
+ Placeholders::HEAD
11
+ elsif @ssr && @ssr.head.present?
12
+ sanitize_ssr(@ssr.head)
13
+ else
14
+ ""
15
+ end
10
16
  end
11
17
 
12
18
  # @!method ssr_body
@@ -14,7 +20,13 @@ module UniversalRenderer
14
20
  # This placeholder is used by the rendering process to inject the main SSR body.
15
21
  # @return [String] The HTML-safe string "<!-- SSR_BODY -->".
16
22
  def ssr_body
17
- Placeholders::BODY.html_safe
23
+ if ssr_streaming?
24
+ Placeholders::BODY
25
+ elsif @ssr && @ssr.body.present?
26
+ sanitize_ssr(@ssr.body)
27
+ else
28
+ ""
29
+ end
18
30
  end
19
31
 
20
32
  # @!method sanitize_ssr(html)
@@ -28,7 +40,7 @@ module UniversalRenderer
28
40
  sanitize(html, scrubber: Scrubber.new)
29
41
  end
30
42
 
31
- # @!method use_ssr_streaming?
43
+ # @!method ssr_streaming?
32
44
  # Determines if SSR streaming should be used for the current request.
33
45
  # The decision is based solely on the `ssr_streaming_preference` class attribute
34
46
  # set on the controller.
@@ -37,9 +49,7 @@ module UniversalRenderer
37
49
  # - If `ssr_streaming_preference` is `nil` (not set), streaming is disabled.
38
50
  # @return [Boolean, nil] The value of `ssr_streaming_preference` (true, false, or nil).
39
51
  # In conditional contexts, `nil` will behave as `false`.
40
- def use_ssr_streaming?
41
- controller.use_ssr_streaming?
42
- end
52
+ delegate :ssr_streaming?, to: :controller
43
53
  end
44
54
  end
45
55
  end
@@ -1,8 +1,8 @@
1
1
  module UniversalRenderer
2
2
  module SSR
3
3
  module Placeholders
4
- META = "<!-- SSR_META -->".freeze
5
- BODY = "<!-- SSR_BODY -->".freeze
4
+ HEAD = "<!-- SSR_HEAD -->".html_safe.freeze
5
+ BODY = "<!-- SSR_BODY -->".html_safe.freeze
6
6
  end
7
7
  end
8
8
  end
@@ -0,0 +1,19 @@
1
+ module UniversalRenderer
2
+ module SSR
3
+ # Lightweight value object representing the payload returned by the
4
+ # Node.js SSR service. Using a Struct keeps the data immutable-ish while
5
+ # still allowing hash-like access (e.g. `response[:head]`).
6
+ #
7
+ # The contract between the Ruby Gem and the Node service guarantees that
8
+ # at minimum the `head` and `body` keys are present. Additional keys are
9
+ # accepted but ignored (see {UniversalRenderer::Client::Base}).
10
+ #
11
+ # @!attribute head
12
+ # @return [String, nil] Raw <head> HTML snippet produced by the renderer.
13
+ # @!attribute body
14
+ # @return [String, nil] Raw body HTML snippet produced by the renderer.
15
+ # @!attribute body_attrs
16
+ # @return [Hash, nil] A hash of attributes that should be applied to the <body> tag.
17
+ Response = Struct.new(:head, :body, :body_attrs, keyword_init: true)
18
+ end
19
+ end
@@ -1,3 +1,3 @@
1
1
  module UniversalRenderer
2
- VERSION = "0.4.0".freeze
2
+ VERSION = "0.4.2".freeze
3
3
  end
@@ -4,6 +4,8 @@ require "universal_renderer/configuration"
4
4
 
5
5
  require "universal_renderer/renderable"
6
6
 
7
+ require "universal_renderer/ssr/response"
8
+
7
9
  require "universal_renderer/client/base"
8
10
  require "universal_renderer/client/stream"
9
11
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: universal_renderer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - thaske
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-05-21 00:00:00.000000000 Z
10
+ date: 2025-05-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: loofah
@@ -27,16 +27,22 @@ dependencies:
27
27
  name: rails
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
30
+ - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: '7.1'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '8.0'
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
36
39
  requirements:
37
- - - "~>"
40
+ - - ">="
38
41
  - !ruby/object:Gem::Version
39
42
  version: '7.1'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '8.0'
40
46
  description: Provides helper methods and configuration to forward rendering requests
41
47
  from a Rails app to an external SSR server and return the response.
42
48
  email:
@@ -48,7 +54,6 @@ files:
48
54
  - MIT-LICENSE
49
55
  - README.md
50
56
  - Rakefile
51
- - config/routes.rb
52
57
  - lib/generators/universal_renderer/install_generator.rb
53
58
  - lib/generators/universal_renderer/templates/initializer.rb
54
59
  - lib/tasks/universal_renderer_tasks.rake
@@ -63,6 +68,7 @@ files:
63
68
  - lib/universal_renderer/renderable.rb
64
69
  - lib/universal_renderer/ssr/helpers.rb
65
70
  - lib/universal_renderer/ssr/placeholders.rb
71
+ - lib/universal_renderer/ssr/response.rb
66
72
  - lib/universal_renderer/ssr/scrubber.rb
67
73
  - lib/universal_renderer/version.rb
68
74
  homepage: https://github.com/thaske/universal_renderer
@@ -72,6 +78,7 @@ metadata:
72
78
  allowed_push_host: https://rubygems.org
73
79
  homepage_uri: https://github.com/thaske/universal_renderer
74
80
  changelog_uri: https://github.com/thaske/universal_renderer/blob/main/CHANGELOG.md
81
+ rubygems_mfa_required: 'true'
75
82
  rdoc_options: []
76
83
  require_paths:
77
84
  - lib
@@ -79,7 +86,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
79
86
  requirements:
80
87
  - - ">="
81
88
  - !ruby/object:Gem::Version
82
- version: 3.0.0
89
+ version: 3.2.0
83
90
  required_rubygems_version: !ruby/object:Gem::Requirement
84
91
  requirements:
85
92
  - - ">="
data/config/routes.rb DELETED
@@ -1 +0,0 @@
1
- Rails.application.routes.draw {}