universal_renderer 0.4.0 → 0.4.1
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 +58 -67
- data/lib/universal_renderer/client/base.rb +48 -47
- data/lib/universal_renderer/client/stream/error_logger.rb +18 -20
- data/lib/universal_renderer/client/stream/execution.rb +18 -74
- data/lib/universal_renderer/client/stream/setup.rb +22 -24
- data/lib/universal_renderer/client/stream.rb +7 -9
- data/lib/universal_renderer/renderable.rb +7 -8
- data/lib/universal_renderer/ssr/helpers.rb +21 -9
- data/lib/universal_renderer/ssr/placeholders.rb +1 -1
- data/lib/universal_renderer/ssr/response.rb +19 -0
- data/lib/universal_renderer/version.rb +1 -1
- data/lib/universal_renderer.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5bc4a2c6514d4a6ae61956f9904ce2387303bbeef0f0f1476a92243405e1489e
|
4
|
+
data.tar.gz: 91fa36be3e8258cf92486ea6631e368e968a8ec67fc7dc5c7732bb9018b92e61
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 84c1091722cad8ba00c15e6ac2254a2fde144db10c2cb2440a5fc5a388dc9d13d6b88af595bc4f53166bc76c413d8fec94508d17565072dbac959cd49142a7e0
|
7
|
+
data.tar.gz: 8417a03a6ec8083a70a721f93e945121007721743d2bee8287899e4b76b52be163753ea945875b722930d96c9ccd3eef3b41b0a2e2de9bdcacf1330e644848a7
|
data/README.md
CHANGED
@@ -16,6 +16,16 @@ UniversalRenderer helps you forward rendering requests to external SSR services,
|
|
16
16
|
- **Automatic fallback** to client-side rendering if SSR fails
|
17
17
|
- **View helpers** for easy integration into your layouts
|
18
18
|
|
19
|
+
## Requirements
|
20
|
+
|
21
|
+
> **Heads-up ⚠️** The JavaScript side of UniversalRenderer is **Bun-native**.
|
22
|
+
>
|
23
|
+
> • You **must** run the SSR server with **Bun ≥ 1.2**.
|
24
|
+
>
|
25
|
+
> • The exported helpers call Bun's built-in HTTP router and `Response` implementation; they **will not boot under Node, Deno, or Cloudflare Workers**.
|
26
|
+
>
|
27
|
+
> • The Ruby gem is runtime-agnostic and continues to work on every platform – only the SSR service requires Bun.
|
28
|
+
|
19
29
|
## Installation
|
20
30
|
|
21
31
|
1. Add to your Gemfile:
|
@@ -65,8 +75,9 @@ class ProductsController < ApplicationController
|
|
65
75
|
|
66
76
|
fetch_ssr # or fetch on demand
|
67
77
|
|
68
|
-
# @ssr will now contain
|
69
|
-
#
|
78
|
+
# @ssr will now contain a UniversalRenderer::SSR::Response which exposes
|
79
|
+
# `.head`, `.body` and optional `.body_attrs` values returned by the SSR
|
80
|
+
# service.
|
70
81
|
end
|
71
82
|
|
72
83
|
def default_render
|
@@ -80,22 +91,17 @@ end
|
|
80
91
|
```erb
|
81
92
|
<%# "ssr/index" %>
|
82
93
|
|
83
|
-
<%#
|
84
|
-
|
85
|
-
|
94
|
+
<%# Inject SSR snippets using the provided helpers. When streaming is enabled
|
95
|
+
these render HTML placeholders (<!-- SSR_HEAD --> / <!-- SSR_BODY -->);
|
96
|
+
otherwise they output the sanitised HTML returned by the SSR service. %>
|
86
97
|
|
87
|
-
|
88
|
-
<%=
|
89
|
-
|
98
|
+
<head>
|
99
|
+
<%= ssr_head %>
|
100
|
+
</head>
|
90
101
|
|
91
102
|
<div id="root">
|
92
|
-
<%=
|
93
|
-
<%= sanitize_ssr @ssr[:root] %>
|
103
|
+
<%= ssr_body %>
|
94
104
|
</div>
|
95
|
-
|
96
|
-
<script id="state" type="application/json">
|
97
|
-
<%= json_escape(@ssr[:state].to_json) %>
|
98
|
-
</script>
|
99
105
|
```
|
100
106
|
|
101
107
|
## Setting Up the SSR Server
|
@@ -119,7 +125,7 @@ To set up the SSR server for your Rails application:
|
|
119
125
|
HelmetProvider,
|
120
126
|
type HelmetDataContext,
|
121
127
|
} from "@dr.pogodin/react-helmet";
|
122
|
-
import {
|
128
|
+
import { QueryClient, QueryClientProvider } from "react-query";
|
123
129
|
import { StaticRouter } from "react-router";
|
124
130
|
import { ServerStyleSheet } from "styled-components";
|
125
131
|
|
@@ -133,7 +139,7 @@ To set up the SSR server for your Rails application:
|
|
133
139
|
const sheet = new ServerStyleSheet();
|
134
140
|
const queryClient = new QueryClient();
|
135
141
|
|
136
|
-
const { query_data } = props;
|
142
|
+
const { query_data = [] } = props;
|
137
143
|
query_data.forEach(({ key, data }) => queryClient.setQueryData(key, data));
|
138
144
|
const state = dehydrate(queryClient);
|
139
145
|
|
@@ -145,29 +151,32 @@ To set up the SSR server for your Rails application:
|
|
145
151
|
<App />
|
146
152
|
</StaticRouter>
|
147
153
|
</QueryClientProvider>
|
154
|
+
<template id="state" data-state={JSON.stringify(state)} />
|
148
155
|
</HelmetProvider>,
|
149
156
|
);
|
150
157
|
|
151
|
-
return { jsx, helmetContext, sheet,
|
158
|
+
return { jsx, helmetContext, sheet, queryClient };
|
152
159
|
}
|
153
160
|
```
|
154
161
|
|
155
|
-
3. Update your `application.tsx` to hydrate the
|
162
|
+
3. Update your `application.tsx` to hydrate on the client:
|
156
163
|
|
157
164
|
```tsx
|
158
165
|
import { HelmetProvider } from "@dr.pogodin/react-helmet";
|
159
|
-
import {
|
160
|
-
import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
|
166
|
+
import { hydrateRoot } from "react-dom/client";
|
161
167
|
import { BrowserRouter } from "react-router";
|
162
|
-
|
168
|
+
import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
|
163
169
|
import App from "@/App";
|
164
170
|
import Metadata from "@/components/Metadata";
|
165
171
|
|
166
172
|
const queryClient = new QueryClient();
|
167
|
-
const stateElement = document.getElementById("state")!;
|
168
|
-
const state = JSON.parse(stateElement.textContent);
|
169
173
|
|
170
|
-
const
|
174
|
+
const stateEl = document.getElementById("state");
|
175
|
+
const state = JSON.parse(stateEl?.dataset.state ?? "{}");
|
176
|
+
stateEl?.remove();
|
177
|
+
|
178
|
+
hydrateRoot(
|
179
|
+
document.getElementById("root")!,
|
171
180
|
<HelmetProvider>
|
172
181
|
<Metadata url={window.location.href} />
|
173
182
|
<QueryClientProvider client={queryClient}>
|
@@ -177,58 +186,40 @@ To set up the SSR server for your Rails application:
|
|
177
186
|
</BrowserRouter>
|
178
187
|
</Hydrate>
|
179
188
|
</QueryClientProvider>
|
180
|
-
</HelmetProvider
|
189
|
+
</HelmetProvider>,
|
181
190
|
);
|
182
|
-
|
183
|
-
const rootElement = document.getElementById("root")!;
|
184
|
-
hydrateRoot(rootElement, app);
|
185
191
|
```
|
186
192
|
|
187
193
|
4. Create an SSR entry point at `app/frontend/ssr/ssr.ts`:
|
188
194
|
|
189
195
|
```ts
|
196
|
+
import { head, transform } from "@/ssr/utils";
|
190
197
|
import { renderToString } from "react-dom/server.node";
|
191
|
-
import {
|
192
|
-
import
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
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
|
-
},
|
198
|
+
import { createServer } from "universal-renderer";
|
199
|
+
import { createServer as createViteServer } from "vite";
|
200
|
+
|
201
|
+
const vite = await createViteServer({
|
202
|
+
server: { middlewareMode: true },
|
203
|
+
appType: "custom",
|
228
204
|
});
|
229
205
|
|
230
|
-
|
231
|
-
|
206
|
+
await createServer({
|
207
|
+
port: 3001,
|
208
|
+
middleware: vite.middlewares,
|
209
|
+
|
210
|
+
setup: (await import("@/ssr/setup")).default,
|
211
|
+
render: ({ app, helmet, sheet }) => {
|
212
|
+
const root = renderToString(app);
|
213
|
+
const styles = sheet.getStyleTags();
|
214
|
+
return {
|
215
|
+
head: head({ helmet }),
|
216
|
+
body: `${root}\n${styles}`,
|
217
|
+
};
|
218
|
+
},
|
219
|
+
cleanup: ({ sheet, queryClient }) => {
|
220
|
+
sheet?.seal();
|
221
|
+
queryClient?.clear();
|
222
|
+
},
|
232
223
|
});
|
233
224
|
```
|
234
225
|
|
@@ -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
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
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
|
-
|
30
|
+
timeout = UniversalRenderer.config.timeout
|
38
31
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
43
|
+
response = http.request(request)
|
51
44
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
65
|
-
rescue StandardError => e
|
56
|
+
else
|
66
57
|
Rails.logger.error(
|
67
|
-
"SSR fetch request to #{ssr_url} failed: #{
|
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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
"
|
10
|
-
|
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
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
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
|
-
|
13
|
+
node_res.read_body { |chunk| response.stream.write(chunk) }
|
27
14
|
else
|
28
|
-
|
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
|
-
|
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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
end
|
5
|
+
def self.ensure_ssr_server_url_configured?(config)
|
6
|
+
config.ssr_url.present?
|
7
|
+
end
|
9
8
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
-
|
18
|
-
|
16
|
+
parsed_ssr_url = URI.parse(config.ssr_url)
|
17
|
+
stream_uri = URI.join(parsed_ssr_url, config.ssr_stream_path)
|
19
18
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
24
|
+
http_request =
|
25
|
+
Net::HTTP::Post.new(
|
26
|
+
stream_uri.request_uri,
|
27
|
+
"Content-Type" => "application/json"
|
28
|
+
)
|
30
29
|
|
31
|
-
|
30
|
+
http_request.body = body.to_json
|
32
31
|
|
33
|
-
|
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.
|
21
|
+
def self.call(url, props, template, response)
|
22
22
|
config = UniversalRenderer.config
|
23
23
|
|
24
|
-
unless Setup.
|
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.
|
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
|
-
|
51
|
-
|
49
|
+
log_setup_error(e, full_ssr_url_for_log)
|
52
50
|
return false
|
53
51
|
end
|
54
52
|
|
55
|
-
Execution.
|
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.
|
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.
|
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,22 +29,21 @@ 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.
|
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
|
38
|
+
def ssr_streaming?
|
39
39
|
self.class.try(:ssr_streaming_preference)
|
40
40
|
end
|
41
41
|
|
42
42
|
def render(*args, **kwargs)
|
43
43
|
return super unless self.class.enable_ssr
|
44
|
-
|
45
44
|
return super unless request.format.html?
|
46
45
|
|
47
|
-
if
|
46
|
+
if ssr_streaming?
|
48
47
|
success = render_ssr_stream(*args, **kwargs)
|
49
48
|
super unless success
|
50
49
|
else
|
@@ -60,11 +59,11 @@ module UniversalRenderer
|
|
60
59
|
current_props = @universal_renderer_props.dup
|
61
60
|
|
62
61
|
streaming_succeeded =
|
63
|
-
UniversalRenderer::Client::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
|
5
|
-
# Outputs a
|
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 "<!--
|
8
|
-
def
|
9
|
-
|
7
|
+
# @return [String] The HTML-safe string "<!-- SSR_HEAD -->".
|
8
|
+
def ssr_head
|
9
|
+
if ssr_streaming?
|
10
|
+
Placeholders::HEAD.html_safe
|
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
|
-
|
23
|
+
if ssr_streaming?
|
24
|
+
Placeholders::BODY.html_safe
|
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
|
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,8 +49,8 @@ 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
|
41
|
-
controller.
|
52
|
+
def ssr_streaming?
|
53
|
+
controller.ssr_streaming?
|
42
54
|
end
|
43
55
|
end
|
44
56
|
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
|
data/lib/universal_renderer.rb
CHANGED
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.
|
4
|
+
version: 0.4.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- thaske
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-05-
|
10
|
+
date: 2025-05-22 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: loofah
|
@@ -63,6 +63,7 @@ files:
|
|
63
63
|
- lib/universal_renderer/renderable.rb
|
64
64
|
- lib/universal_renderer/ssr/helpers.rb
|
65
65
|
- lib/universal_renderer/ssr/placeholders.rb
|
66
|
+
- lib/universal_renderer/ssr/response.rb
|
66
67
|
- lib/universal_renderer/ssr/scrubber.rb
|
67
68
|
- lib/universal_renderer/version.rb
|
68
69
|
homepage: https://github.com/thaske/universal_renderer
|