universal_renderer 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +410 -178
- data/lib/generators/universal_renderer/install_generator.rb +61 -3
- data/lib/generators/universal_renderer/templates/initializer.rb +8 -5
- data/lib/generators/universal_renderer/templates/ssr/config.ts +40 -0
- data/lib/generators/universal_renderer/templates/ssr/dev.ts +9 -0
- data/lib/generators/universal_renderer/templates/ssr/globals.ts +141 -0
- data/lib/generators/universal_renderer/templates/ssr/server.ts +24 -0
- data/lib/generators/universal_renderer/templates/ssr.rake +27 -0
- data/lib/generators/universal_renderer/templates/vite.config.ssr.mts +13 -0
- data/lib/generators/universal_renderer/templates/web +94 -0
- data/lib/universal_renderer/client/base.rb +99 -44
- data/lib/universal_renderer/client/stream/error_logger.rb +25 -3
- data/lib/universal_renderer/client/stream/execution.rb +27 -1
- data/lib/universal_renderer/client/stream/setup.rb +6 -1
- data/lib/universal_renderer/client/stream.rb +49 -12
- data/lib/universal_renderer/configuration.rb +56 -6
- data/lib/universal_renderer/engine.rb +9 -2
- data/lib/universal_renderer/instrumentation.rb +47 -0
- data/lib/universal_renderer/renderable.rb +220 -86
- data/lib/universal_renderer/ssr/helpers.rb +82 -57
- data/lib/universal_renderer/ssr/response.rb +6 -1
- data/lib/universal_renderer/ssr/scrubber.rb +119 -30
- data/lib/universal_renderer/version.rb +1 -1
- data/lib/universal_renderer.rb +1 -0
- metadata +9 -1
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
module UniversalRenderer
|
|
2
|
+
# Controller-side entry point for server-side rendering. Drive it either
|
|
3
|
+
# declaratively with {ClassMethods#enable_ssr}, or imperatively by calling
|
|
4
|
+
# {#render_ssr} from the action. A failed or unconfigured render is a no-op:
|
|
5
|
+
# {#ssr?} returns false and the layout falls back to client-side rendering.
|
|
2
6
|
module Renderable
|
|
3
7
|
extend ActiveSupport::Concern
|
|
4
8
|
|
|
5
9
|
included do
|
|
6
10
|
helper UniversalRenderer::SSR::Helpers
|
|
7
11
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# (arming SSR as a side effect) instead of returning false.
|
|
12
|
+
# Not named `enable_ssr`: reading a class_attribute by that name would
|
|
13
|
+
# invoke the DSL method and arm SSR as a side effect.
|
|
11
14
|
class_attribute :ssr_enabled, instance_writer: false, default: false
|
|
12
15
|
class_attribute :ssr_streaming_preference,
|
|
13
16
|
instance_writer: false,
|
|
14
17
|
default: nil
|
|
18
|
+
class_attribute :ssr_conditions, instance_writer: false, default: {}
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
helper_method :ssr?, :ssr_response, :ssr_streaming?
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
module Streaming
|
|
@@ -22,98 +26,117 @@ module UniversalRenderer
|
|
|
22
26
|
included { include ActionController::Live }
|
|
23
27
|
end
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
module ClassMethods
|
|
30
|
+
# Arms the automatic render path for this controller.
|
|
31
|
+
#
|
|
32
|
+
# @param options [Hash]
|
|
33
|
+
# @option options [Boolean] :streaming Stream the response instead of
|
|
34
|
+
# fetching it in one blocking request. Mixes in ActionController::Live.
|
|
35
|
+
# @option options [Symbol, Array<Symbol>] :only Restrict to these actions.
|
|
36
|
+
# @option options [Symbol, Array<Symbol>] :except Skip these actions.
|
|
37
|
+
# @option options [Symbol, Proc] :if Render only when this evaluates
|
|
38
|
+
# truthy. A Symbol names a controller method; a Proc is instance_exec'd
|
|
39
|
+
# against the controller.
|
|
40
|
+
# @option options [Symbol, Proc] :unless Skip when this evaluates truthy.
|
|
41
|
+
#
|
|
42
|
+
# @example Public pages only
|
|
43
|
+
# enable_ssr only: :show, unless: -> { current_user.present? }
|
|
44
|
+
# @return [void]
|
|
26
45
|
def enable_ssr(options = {})
|
|
27
46
|
self.ssr_enabled = true
|
|
28
47
|
self.ssr_streaming_preference = options[:streaming]
|
|
48
|
+
self.ssr_conditions = options.slice(:only, :except, :if, :unless).freeze
|
|
29
49
|
|
|
30
50
|
include UniversalRenderer::Renderable::Streaming if options[:streaming]
|
|
31
51
|
end
|
|
32
52
|
end
|
|
33
53
|
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# result in the `@ssr` instance variable.
|
|
54
|
+
# The props accumulated for this request. Mutating the returned hash is
|
|
55
|
+
# supported, but prefer {#add_prop} / {#push_prop} / {#add_query_data}.
|
|
37
56
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def fetch_ssr
|
|
44
|
-
props = @universal_renderer_props || {}
|
|
45
|
-
@ssr =
|
|
46
|
-
UniversalRenderer::Client::Base.call(
|
|
47
|
-
request.original_url,
|
|
48
|
-
props
|
|
49
|
-
)
|
|
57
|
+
# @return [Hash]
|
|
58
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName -- the ivar name is
|
|
59
|
+
# part of the pre-0.6 surface; layouts and specs in the wild read it.
|
|
60
|
+
def ssr_props
|
|
61
|
+
@universal_renderer_props ||= {}
|
|
50
62
|
end
|
|
63
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
51
64
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
# Fetches the SSR payload for the current request and remembers it, so the
|
|
66
|
+
# view helpers and {#ssr?} can see it. Idempotent.
|
|
67
|
+
#
|
|
68
|
+
# @param props [Hash, nil] Props to merge first. Ignored once a render has
|
|
69
|
+
# happened, since merging then would change `ssr_props` without affecting
|
|
70
|
+
# the response.
|
|
71
|
+
# @return [UniversalRenderer::SSR::Response, nil] `nil` when SSR is not
|
|
72
|
+
# configured or the render failed.
|
|
73
|
+
def render_ssr(props = nil)
|
|
74
|
+
return @_ssr_response if defined?(@_ssr_response)
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
return super unless self.class.ssr_enabled
|
|
58
|
-
return super unless request.format.html?
|
|
76
|
+
add_prop(props) if props.present?
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if ssr_streaming?
|
|
63
|
-
success = render_ssr_stream(*, **)
|
|
64
|
-
super unless success
|
|
65
|
-
else
|
|
66
|
-
fetch_ssr
|
|
67
|
-
super
|
|
68
|
-
end
|
|
69
|
-
end
|
|
78
|
+
@_ssr_response =
|
|
79
|
+
UniversalRenderer::Client::Base.call(request.original_url, ssr_props)
|
|
70
80
|
|
|
71
|
-
|
|
81
|
+
@ssr = @_ssr_response # pre-0.6 layouts read this ivar
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
current_props = (@universal_renderer_props || {}).dup
|
|
83
|
+
@_ssr_response
|
|
84
|
+
end
|
|
76
85
|
|
|
77
|
-
|
|
78
|
-
UniversalRenderer::Client::Stream.call(
|
|
79
|
-
request.original_url,
|
|
80
|
-
current_props,
|
|
81
|
-
full_layout,
|
|
82
|
-
response
|
|
83
|
-
)
|
|
86
|
+
alias fetch_ssr render_ssr
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"Streaming failed, proceeding with standard rendering."
|
|
93
|
-
)
|
|
94
|
-
end
|
|
95
|
-
false
|
|
96
|
-
end
|
|
88
|
+
# @return [UniversalRenderer::SSR::Response, nil] The payload from the most
|
|
89
|
+
# recent {#render_ssr}, or nil if none succeeded.
|
|
90
|
+
def ssr_response
|
|
91
|
+
return @_ssr_response if defined?(@_ssr_response)
|
|
92
|
+
|
|
93
|
+
# Tolerate layouts and controllers that assigned @ssr by hand.
|
|
94
|
+
@ssr
|
|
97
95
|
end
|
|
98
96
|
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
# Whether this request has server-rendered content to emit. Use it to pick
|
|
98
|
+
# between a hydration entry point and a client-render entry point.
|
|
99
|
+
#
|
|
100
|
+
# True while streaming too, where the HTML arrives after the layout renders.
|
|
101
|
+
#
|
|
102
|
+
# @return [Boolean]
|
|
103
|
+
def ssr?
|
|
104
|
+
ssr_streaming? || ssr_response.present?
|
|
101
105
|
end
|
|
102
106
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
# Whether this *request* is being streamed.
|
|
108
|
+
#
|
|
109
|
+
# False unless the request would also stream, so the layout never emits the
|
|
110
|
+
# `<!-- SSR_HEAD -->` / `<!-- SSR_BODY -->` markers into a page no renderer
|
|
111
|
+
# will see. A failed stream downgrades this for the same reason.
|
|
112
|
+
#
|
|
113
|
+
# @return [Boolean]
|
|
114
|
+
def ssr_streaming?
|
|
115
|
+
return @_ssr_streaming if defined?(@_ssr_streaming)
|
|
116
|
+
|
|
117
|
+
self.class.ssr_streaming_preference.present? && ssr_enabled_for_request?
|
|
118
|
+
end
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
# Render options that mean "this is not a page". The request format is still
|
|
121
|
+
# HTML for a `render json:` inside a form post, or for a Turbo Frame, so the
|
|
122
|
+
# format check alone does not catch them.
|
|
123
|
+
NON_PAGE_RENDER_OPTIONS = %i[
|
|
124
|
+
body file inline js json nothing partial plain xml
|
|
125
|
+
].freeze
|
|
111
126
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return
|
|
127
|
+
def render(*, **options)
|
|
128
|
+
return super unless ssr_enabled_for_request?
|
|
129
|
+
return super if options.keys.intersect?(NON_PAGE_RENDER_OPTIONS)
|
|
130
|
+
# No layout, so nothing calls the helpers that would emit the payload.
|
|
131
|
+
return super if options[:layout] == false
|
|
115
132
|
|
|
116
|
-
|
|
133
|
+
if ssr_streaming?
|
|
134
|
+
success = render_ssr_stream(*, **options)
|
|
135
|
+
super unless success
|
|
136
|
+
else
|
|
137
|
+
render_ssr
|
|
138
|
+
super
|
|
139
|
+
end
|
|
117
140
|
end
|
|
118
141
|
|
|
119
142
|
# Adds a prop or a hash of props to be sent to the SSR service.
|
|
@@ -128,16 +151,15 @@ module UniversalRenderer
|
|
|
128
151
|
# add_prop({theme: "dark", locale: "en"})
|
|
129
152
|
# @return [void]
|
|
130
153
|
def add_prop(key_or_hash, data_value = nil)
|
|
131
|
-
@universal_renderer_props ||= {}
|
|
132
154
|
if data_value.nil? && key_or_hash.is_a?(Hash)
|
|
133
|
-
|
|
155
|
+
ssr_props.merge!(key_or_hash.deep_stringify_keys)
|
|
134
156
|
else
|
|
135
|
-
|
|
157
|
+
ssr_props[key_or_hash.to_s] = data_value
|
|
136
158
|
end
|
|
137
159
|
end
|
|
138
160
|
|
|
139
161
|
# Allows a prop to be treated as an array, pushing new values to it.
|
|
140
|
-
# If the prop does not exist or is `nil`, it
|
|
162
|
+
# If the prop does not exist or is `nil`, it's initialized as an empty array.
|
|
141
163
|
# If the prop exists but is not an array (e.g., set as a scalar by `add_prop`),
|
|
142
164
|
# its current value will be converted into the first element of the new array.
|
|
143
165
|
# If `value_to_add` is an array, its elements are concatenated to the existing array.
|
|
@@ -151,38 +173,150 @@ module UniversalRenderer
|
|
|
151
173
|
# push_prop(:tags, ["rails", "ruby"])
|
|
152
174
|
# @example Appending to an existing scalar value (converts to array)
|
|
153
175
|
# add_prop(:item, "first")
|
|
154
|
-
# push_prop(:item, "second") #
|
|
176
|
+
# push_prop(:item, "second") # ssr_props becomes { "item" => ["first", "second"] }
|
|
155
177
|
# @return [void]
|
|
156
178
|
def push_prop(key, value_to_add)
|
|
157
|
-
|
|
179
|
+
props = ssr_props
|
|
158
180
|
prop_key = key.to_s
|
|
159
|
-
current_value =
|
|
181
|
+
current_value = props[prop_key]
|
|
160
182
|
|
|
161
183
|
if current_value.nil?
|
|
162
|
-
|
|
184
|
+
props[prop_key] = []
|
|
163
185
|
elsif !current_value.is_a?(Array)
|
|
164
|
-
|
|
186
|
+
props[prop_key] = [current_value]
|
|
165
187
|
end
|
|
166
|
-
# At this point,
|
|
188
|
+
# At this point, props[prop_key] is guaranteed to be an array.
|
|
167
189
|
|
|
168
190
|
if value_to_add.is_a?(Array)
|
|
169
|
-
|
|
191
|
+
props[prop_key].concat(value_to_add)
|
|
170
192
|
else
|
|
171
|
-
|
|
193
|
+
props[prop_key] << value_to_add
|
|
172
194
|
end
|
|
173
195
|
end
|
|
174
196
|
|
|
175
197
|
# Adds a React Query cache entry that can be hydrated on SSR/client boot.
|
|
176
198
|
#
|
|
199
|
+
# Entries accumulate under the `react_query` prop as
|
|
200
|
+
# `{ "query_key" => [...], "data" => ... }`. The NPM package's
|
|
201
|
+
# `hydrateReactQuery(props, queryClient)` consumes exactly this shape — use
|
|
202
|
+
# it in `setup` rather than reimplementing the loop.
|
|
203
|
+
#
|
|
177
204
|
# @param query_key [Array, String, Symbol] The React Query key.
|
|
178
205
|
# @param data [Object] The cached query data.
|
|
179
206
|
# @return [void]
|
|
180
207
|
def add_query_data(query_key, data)
|
|
181
|
-
|
|
208
|
+
parts = query_key.is_a?(Array) ? query_key : [query_key]
|
|
209
|
+
|
|
210
|
+
# React Query compares keys structurally, so numeric parts have to stay
|
|
211
|
+
# numeric. `deep_stringify_keys` below only touches hash keys.
|
|
212
|
+
normalized = parts.map { |part| part.is_a?(Symbol) ? part.to_s : part }
|
|
213
|
+
|
|
182
214
|
push_prop(
|
|
183
215
|
:react_query,
|
|
184
|
-
{ query_key:
|
|
216
|
+
{ query_key: normalized, data: data }.deep_stringify_keys
|
|
185
217
|
)
|
|
186
218
|
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# Whether the automatic (`enable_ssr`) path should run for this request.
|
|
223
|
+
# {#render_ssr} deliberately does not consult this: an explicit call is the
|
|
224
|
+
# caller stating intent.
|
|
225
|
+
#
|
|
226
|
+
# Memoized so a caller's `if:` / `unless:` predicate runs once, however many
|
|
227
|
+
# times the layout asks through `ssr_streaming?`.
|
|
228
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName -- ivars this concern
|
|
229
|
+
# sets into a host controller are `_`-prefixed to avoid collisions.
|
|
230
|
+
def ssr_enabled_for_request?
|
|
231
|
+
return @_ssr_enabled_for_request if defined?(@_ssr_enabled_for_request)
|
|
232
|
+
|
|
233
|
+
@_ssr_enabled_for_request = compute_ssr_enabled_for_request?
|
|
234
|
+
end
|
|
235
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
236
|
+
|
|
237
|
+
def compute_ssr_enabled_for_request?
|
|
238
|
+
return false unless self.class.ssr_enabled
|
|
239
|
+
return false unless request.format.html?
|
|
240
|
+
|
|
241
|
+
# Only while a Warden throw/catch is in flight, so SSR still runs for
|
|
242
|
+
# public pages viewed by unauthenticated visitors.
|
|
243
|
+
return false if defined?(Warden) && request.env["warden"]&.message.present?
|
|
244
|
+
|
|
245
|
+
ssr_conditions_met?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def ssr_conditions_met?
|
|
249
|
+
conditions = self.class.ssr_conditions
|
|
250
|
+
return true if conditions.blank?
|
|
251
|
+
|
|
252
|
+
ssr_action_allowed?(conditions) && ssr_guards_pass?(conditions)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def ssr_action_allowed?(conditions)
|
|
256
|
+
action = action_name.to_s
|
|
257
|
+
|
|
258
|
+
only = conditions[:only]
|
|
259
|
+
return false if only && Array(only).map(&:to_s).exclude?(action)
|
|
260
|
+
|
|
261
|
+
except = conditions[:except]
|
|
262
|
+
return false if except && Array(except).map(&:to_s).include?(action)
|
|
263
|
+
|
|
264
|
+
true
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def ssr_guards_pass?(conditions)
|
|
268
|
+
if_condition = conditions[:if]
|
|
269
|
+
return false if if_condition && !evaluate_ssr_condition(if_condition)
|
|
270
|
+
|
|
271
|
+
unless_condition = conditions[:unless]
|
|
272
|
+
return false if unless_condition &&
|
|
273
|
+
evaluate_ssr_condition(unless_condition)
|
|
274
|
+
|
|
275
|
+
true
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def evaluate_ssr_condition(condition)
|
|
279
|
+
case condition
|
|
280
|
+
when Symbol, String
|
|
281
|
+
send(condition)
|
|
282
|
+
when Proc
|
|
283
|
+
condition.arity.zero? ? instance_exec(&condition) : condition.call(self)
|
|
284
|
+
else
|
|
285
|
+
raise ArgumentError,
|
|
286
|
+
"enable_ssr conditions must be a Symbol, String, or Proc, " \
|
|
287
|
+
"got #{condition.class}"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def render_ssr_stream(*, **)
|
|
292
|
+
# Rendered while `ssr_streaming?` is still true, so the layout carries the
|
|
293
|
+
# markers the SSR service splices into.
|
|
294
|
+
full_layout = render_to_string(*, **)
|
|
295
|
+
|
|
296
|
+
streaming_succeeded =
|
|
297
|
+
UniversalRenderer::Client::Stream.call(
|
|
298
|
+
request.original_url,
|
|
299
|
+
ssr_props.dup,
|
|
300
|
+
full_layout,
|
|
301
|
+
response
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if streaming_succeeded
|
|
305
|
+
response.stream.close unless response.stream.closed?
|
|
306
|
+
true
|
|
307
|
+
else
|
|
308
|
+
# Nothing was written upstream, so the caller re-renders normally.
|
|
309
|
+
# Downgrade first, or the fallback page ships bare markers and no content.
|
|
310
|
+
@_ssr_streaming = false
|
|
311
|
+
|
|
312
|
+
UniversalRenderer.log do |log|
|
|
313
|
+
log.error(
|
|
314
|
+
"SSR stream fallback: " \
|
|
315
|
+
"Streaming failed, proceeding with standard rendering."
|
|
316
|
+
)
|
|
317
|
+
end
|
|
318
|
+
false
|
|
319
|
+
end
|
|
320
|
+
end
|
|
187
321
|
end
|
|
188
322
|
end
|
|
@@ -1,79 +1,104 @@
|
|
|
1
1
|
module UniversalRenderer
|
|
2
2
|
module SSR
|
|
3
|
+
# View helpers for emitting what the SSR service returned. `ssr?`,
|
|
4
|
+
# `ssr_response`, and `ssr_streaming?` come from the controller via
|
|
5
|
+
# `helper_method` (see {UniversalRenderer::Renderable}).
|
|
3
6
|
module Helpers
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
7
|
-
#
|
|
7
|
+
SAFE_BODY_ATTRIBUTE_NAME = /\A[a-z_:][a-z0-9:._-]*\z/i
|
|
8
|
+
|
|
9
|
+
# Server-rendered <head> content, or the streaming placeholder.
|
|
10
|
+
#
|
|
11
|
+
# @return [String] Sanitized head HTML, the `<!-- SSR_HEAD -->` marker
|
|
12
|
+
# when streaming, or an empty string when there is nothing to emit.
|
|
8
13
|
def ssr_head
|
|
9
|
-
if ssr_streaming?
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
else
|
|
14
|
-
""
|
|
15
|
-
end
|
|
14
|
+
return Placeholders::HEAD if ssr_streaming?
|
|
15
|
+
|
|
16
|
+
html = ssr_response&.head
|
|
17
|
+
html.present? ? sanitize_ssr(html) : ""
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
20
|
+
# Server-rendered body content, or the streaming placeholder.
|
|
21
|
+
#
|
|
22
|
+
# @return [String] Sanitized body HTML, the `<!-- SSR_BODY -->` marker
|
|
23
|
+
# when streaming, or an empty string when there is nothing to emit.
|
|
22
24
|
def ssr_body
|
|
23
|
-
if ssr_streaming?
|
|
24
|
-
Placeholders::BODY
|
|
25
|
-
elsif @ssr && @ssr.body.present?
|
|
26
|
-
sanitize_ssr(@ssr.body)
|
|
27
|
-
else
|
|
28
|
-
""
|
|
29
|
-
end
|
|
30
|
-
end
|
|
25
|
+
return Placeholders::BODY if ssr_streaming?
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# Uses a custom scrubber ({UniversalRenderer::SSR::Scrubber}) to remove potentially
|
|
35
|
-
# harmful elements like scripts and event handlers, while allowing safe tags
|
|
36
|
-
# like stylesheets and meta tags.
|
|
37
|
-
# @param html [String] The HTML string to sanitize.
|
|
38
|
-
# @return [String] The sanitized HTML string.
|
|
39
|
-
def sanitize_ssr(html)
|
|
40
|
-
sanitize(html, scrubber: Scrubber.new)
|
|
27
|
+
html = ssr_response&.body
|
|
28
|
+
html.present? ? sanitize_ssr(html) : ""
|
|
41
29
|
end
|
|
42
30
|
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
31
|
+
# Attributes the renderer asked to be applied to the `<body>` tag:
|
|
32
|
+
#
|
|
33
|
+
# <body class="app" <%= ssr_body_attributes %>>
|
|
34
|
+
#
|
|
35
|
+
# A non-Hash is dropped rather than raised on, so a renderer answering 200
|
|
36
|
+
# with the wrong shape degrades the page instead of breaking the layout.
|
|
37
|
+
#
|
|
38
|
+
# @return [ActiveSupport::SafeBuffer] Escaped `name="value"` pairs, or an
|
|
39
|
+
# empty buffer when the renderer sent none.
|
|
40
|
+
def ssr_body_attributes
|
|
41
|
+
attrs = ssr_response&.body_attrs
|
|
42
|
+
return "".html_safe unless attrs.is_a?(Hash)
|
|
43
|
+
return "".html_safe if attrs.empty?
|
|
44
|
+
|
|
45
|
+
attrs = sanitize_ssr_body_attributes(attrs) if UniversalRenderer.config.sanitize
|
|
46
|
+
tag.attributes(attrs)
|
|
51
47
|
end
|
|
52
48
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
49
|
+
# Renders the renderer's hydration payload as an inert JSON script tag.
|
|
50
|
+
#
|
|
51
|
+
# Return it as `payload` from your `render` callback and read it on the
|
|
52
|
+
# client with `JSON.parse(el.textContent)`.
|
|
53
|
+
#
|
|
54
|
+
# @param id [String] DOM id for the script element.
|
|
55
|
+
# @return [ActiveSupport::SafeBuffer, nil] The script tag, or nil when the
|
|
56
|
+
# renderer sent no payload.
|
|
57
|
+
def ssr_payload(id: "ssr-payload")
|
|
58
|
+
payload = ssr_response&.payload
|
|
59
|
+
return if payload.nil?
|
|
60
|
+
|
|
59
61
|
content_tag(
|
|
60
62
|
:script,
|
|
61
|
-
|
|
63
|
+
ERB::Util.json_escape(payload.to_json),
|
|
62
64
|
{ id: id, type: "application/json" },
|
|
63
65
|
false
|
|
64
66
|
)
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
69
|
+
# These back the helpers above; the module is included into the view
|
|
70
|
+
# context, so a public method here would be callable from any template.
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Sanitizes HTML returned by the SSR service, honouring `config.sanitize`
|
|
74
|
+
# and `config.scrubber`.
|
|
75
|
+
#
|
|
76
|
+
# @param html [String] The HTML string to sanitize.
|
|
77
|
+
# @return [String] The sanitized HTML, or the input marked html_safe when
|
|
78
|
+
# sanitization is disabled.
|
|
79
|
+
def sanitize_ssr(html)
|
|
80
|
+
config = UniversalRenderer.config
|
|
81
|
+
# rubocop:disable Rails/OutputSafety -- opting out of sanitization is the
|
|
82
|
+
# documented meaning of config.sanitize = false.
|
|
83
|
+
return html.to_s.html_safe unless config.sanitize
|
|
84
|
+
# rubocop:enable Rails/OutputSafety
|
|
85
|
+
|
|
86
|
+
sanitize(html, scrubber: config.scrubber || Scrubber.new)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `tag.attributes` escapes values but does not reject executable names such
|
|
90
|
+
# as `onload`, so the body-attribute channel needs the same boundary as
|
|
91
|
+
# `ssr_head` and `ssr_body`.
|
|
92
|
+
def sanitize_ssr_body_attributes(attrs)
|
|
93
|
+
attrs.each_with_object({}) do |(name, value), safe|
|
|
94
|
+
normalized = name.to_s.downcase
|
|
95
|
+
next unless normalized.match?(SAFE_BODY_ATTRIBUTE_NAME)
|
|
96
|
+
next if normalized.start_with?("on") || normalized == "srcdoc"
|
|
97
|
+
|
|
98
|
+
safe[name] = value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
77
102
|
end
|
|
78
103
|
end
|
|
79
104
|
end
|
|
@@ -14,6 +14,11 @@ module UniversalRenderer
|
|
|
14
14
|
# @return [String, nil] Raw body HTML snippet produced by the renderer.
|
|
15
15
|
# @!attribute body_attrs
|
|
16
16
|
# @return [Hash, nil] A hash of attributes that should be applied to the <body> tag.
|
|
17
|
-
|
|
17
|
+
# @!attribute payload
|
|
18
|
+
# @return [Object, nil] Arbitrary JSON-serializable hydration state the
|
|
19
|
+
# renderer produced, such as a dehydrated query cache. Emit it with the
|
|
20
|
+
# `ssr_payload` helper.
|
|
21
|
+
Response =
|
|
22
|
+
Struct.new(:head, :body, :body_attrs, :payload, keyword_init: true)
|
|
18
23
|
end
|
|
19
24
|
end
|