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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66a7c97479e5a19a14c90c30fc4e2c4a443df850f4feb78a3ce362ed844c9329
|
|
4
|
+
data.tar.gz: ec30ad10cace480af52901dd9c9e5212377b339884c5e53f1989211d98de350e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b6aaf0cf5262ad208c0ddeb765ad2b938925c8c161897c1443b51288b0c986697d0c57ad4397286f3a46d03908ed6ef6c1faeda678e96f8eee318f877df6a7e
|
|
7
|
+
data.tar.gz: 648177df24585900b57ee41a01fc4e056043ea4217bde65cc8bab040700b35a9b9f6b8d79903b5ebe75c442ef9b0b3cf31d347be17ab116e991269e065bfd1dc
|
data/README.md
CHANGED
|
@@ -4,221 +4,453 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://rubygems.org/gems/universal_renderer) [](https://www.npmjs.com/package/universal-renderer)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Server-Side Rendering for Rails apps with a React front end.
|
|
8
8
|
|
|
9
9
|
## Overview
|
|
10
10
|
|
|
11
|
-
UniversalRenderer
|
|
11
|
+
UniversalRenderer runs your React app in a small Node/Bun service and splices the
|
|
12
|
+
result into your Rails layout. It is two halves of one contract: the
|
|
13
|
+
`universal_renderer` gem talks to the `universal-renderer` NPM package.
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
The design assumption is retrofitting: you have a working client-rendered app,
|
|
16
|
+
you want the first paint to be server-rendered, and you cannot afford for SSR to
|
|
17
|
+
become a source of outages. So every failure path — service down, timeout, 500,
|
|
18
|
+
not configured — falls back to client-side rendering silently, and the
|
|
19
|
+
instrumentation exists so "silently" does not mean "invisibly".
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
### Version compatibility
|
|
22
|
+
|
|
23
|
+
The two packages share a wire format, so upgrade them together.
|
|
24
|
+
|
|
25
|
+
| Gem | NPM package | Notes |
|
|
26
|
+
| ------- | ----------- | ------------------------------------------------------------ |
|
|
27
|
+
| `0.7.x` | `0.7.x` | `payload`, conditional `enable_ssr`, `concurrency`, `prepare` |
|
|
28
|
+
| `0.5.x` | `0.6.x` | |
|
|
20
29
|
|
|
21
30
|
## Installation
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
```ruby
|
|
33
|
+
# Gemfile
|
|
34
|
+
gem "universal_renderer"
|
|
35
|
+
```
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
```bash
|
|
38
|
+
bundle install
|
|
39
|
+
bun add universal-renderer # or npm / yarn
|
|
40
|
+
bin/rails generate universal_renderer:install
|
|
41
|
+
```
|
|
28
42
|
|
|
29
|
-
|
|
43
|
+
The generator writes the initializer, the two renderer entry points, the SSR Vite
|
|
44
|
+
build, the `assets:precompile` hook, and `bin/web`. Pass
|
|
45
|
+
`--frontend-dir=app/client` if your JavaScript is not in `app/frontend`.
|
|
30
46
|
|
|
31
|
-
|
|
32
|
-
$ bundle install
|
|
33
|
-
```
|
|
47
|
+
## Rendering from a controller
|
|
34
48
|
|
|
35
|
-
|
|
49
|
+
There are two ways in, and they are equally supported.
|
|
36
50
|
|
|
37
|
-
|
|
38
|
-
$ rails generate universal_renderer:install
|
|
39
|
-
```
|
|
51
|
+
**Declarative**, when the whole action is server-rendered:
|
|
40
52
|
|
|
41
|
-
|
|
53
|
+
```ruby
|
|
54
|
+
class ArticlesController < ApplicationController
|
|
55
|
+
enable_ssr only: :show, unless: -> { current_user.present? }
|
|
56
|
+
|
|
57
|
+
def show
|
|
58
|
+
@article = Article.find(params[:id])
|
|
59
|
+
add_query_data(["articles", @article.slug], article_json)
|
|
60
|
+
render "common/js_only"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
42
64
|
|
|
43
|
-
|
|
65
|
+
`enable_ssr` takes `only:`, `except:`, `if:`, and `unless:` (Symbol or Proc,
|
|
66
|
+
evaluated against the controller), plus `streaming: true`.
|
|
67
|
+
|
|
68
|
+
**Imperative**, when the decision or the data depends on request state:
|
|
44
69
|
|
|
45
70
|
```ruby
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
def show
|
|
72
|
+
@article = Article.find(params[:id])
|
|
73
|
+
return render("common/js_only") unless public_view?
|
|
74
|
+
|
|
75
|
+
add_query_data(["articles", @article.slug], article_json)
|
|
76
|
+
add_prop(:feature_flags, enabled_flags)
|
|
77
|
+
render_ssr
|
|
51
78
|
|
|
52
|
-
|
|
53
|
-
# enable_ssr streaming: true
|
|
79
|
+
render "common/js_only"
|
|
54
80
|
end
|
|
55
81
|
```
|
|
56
82
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
`render_ssr` fetches once, memoizes, and returns the payload — or `nil`, which is
|
|
84
|
+
your signal to let the client-rendered path stand. It works whether or not the
|
|
85
|
+
controller called `enable_ssr`.
|
|
86
|
+
|
|
87
|
+
### Props
|
|
88
|
+
|
|
89
|
+
| Method | Effect |
|
|
90
|
+
| ------------------------------- | --------------------------------------------------------------- |
|
|
91
|
+
| `add_prop(key, value)` | Sets one prop. Also takes a hash. |
|
|
92
|
+
| `push_prop(key, value)` | Appends to an array prop. |
|
|
93
|
+
| `add_query_data(key, data)` | Adds a React Query cache entry under the `react_query` prop. |
|
|
94
|
+
| `ssr_props` | The accumulated hash, if you need to inspect or merge it. |
|
|
95
|
+
|
|
96
|
+
## Rendering in the layout
|
|
97
|
+
|
|
98
|
+
```erb
|
|
99
|
+
<head>
|
|
100
|
+
<%= ssr_head %>
|
|
101
|
+
<%= ssr_payload %>
|
|
102
|
+
</head>
|
|
103
|
+
<body class="app" <%= ssr_body_attributes %>>
|
|
104
|
+
<div id="root"><%= ssr_body %></div>
|
|
105
|
+
|
|
106
|
+
<%# Server-rendered pages hydrate; everything else boots a client render. %>
|
|
107
|
+
<%= vite_typescript_tag ssr? ? "hydrate.tsx" : "application.tsx" %>
|
|
108
|
+
</body>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Every helper is a no-op when there is nothing to emit, so the layout needs no
|
|
112
|
+
conditionals. `ssr?` is there for the decisions that are not about emitting HTML —
|
|
113
|
+
picking an entry point, skipping a preload — and it never requires reading an
|
|
114
|
+
instance variable. It is true for streamed pages too, which are server-rendered
|
|
115
|
+
even though the payload arrives after the layout.
|
|
116
|
+
|
|
117
|
+
`ssr_payload` emits whatever your `render` callback returned as `payload`, as an
|
|
118
|
+
inert `<script type="application/json">`, escaped. That is the channel for
|
|
119
|
+
hydration state, because the interesting state is only known after the render: a
|
|
120
|
+
dehydrated query cache, the class names a CSS-in-JS library already wrote into
|
|
121
|
+
`head`. Do not hand-roll a script tag inside `head`.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
62
124
|
|
|
63
125
|
```ruby
|
|
64
126
|
UniversalRenderer.configure do |c|
|
|
65
127
|
c.url = ENV.fetch("UNIVERSAL_RENDERER_URL", "http://localhost:3001")
|
|
66
|
-
c.timeout =
|
|
67
|
-
#
|
|
128
|
+
c.timeout = 3
|
|
129
|
+
c.stream_path = "/stream" # must match the Node side's `paths`
|
|
130
|
+
c.http.pool_size = 5
|
|
68
131
|
end
|
|
69
132
|
```
|
|
70
133
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
To set up the SSR server for your Rails application:
|
|
74
|
-
|
|
75
|
-
1. Install the NPM package in your JavaScript project:
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
$ npm install universal-renderer
|
|
79
|
-
# or
|
|
80
|
-
$ yarn add universal-renderer
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
2. Create a `setup` function at `app/frontend/ssr/setup.ts`:
|
|
84
|
-
|
|
85
|
-
```tsx
|
|
86
|
-
import {
|
|
87
|
-
HelmetProvider,
|
|
88
|
-
type HelmetDataContext,
|
|
89
|
-
} from "@dr.pogodin/react-helmet";
|
|
90
|
-
import { QueryClient, QueryClientProvider } from "react-query";
|
|
91
|
-
import { StaticRouter } from "react-router";
|
|
92
|
-
import { ServerStyleSheet } from "styled-components";
|
|
93
|
-
|
|
94
|
-
import App from "@/App";
|
|
95
|
-
import Metadata from "@/components/Metadata";
|
|
96
|
-
|
|
97
|
-
export default function setup(url: string, props: any) {
|
|
98
|
-
const pathname = new URL(url).pathname;
|
|
99
|
-
|
|
100
|
-
const helmetContext: HelmetDataContext = {};
|
|
101
|
-
const sheet = new ServerStyleSheet();
|
|
102
|
-
const queryClient = new QueryClient();
|
|
103
|
-
|
|
104
|
-
const { query_data = [] } = props;
|
|
105
|
-
query_data.forEach(({ key, data }) => queryClient.setQueryData(key, data));
|
|
106
|
-
const state = dehydrate(queryClient);
|
|
107
|
-
|
|
108
|
-
const app = sheet.collectStyles(
|
|
109
|
-
<HelmetProvider context={helmetContext}>
|
|
110
|
-
<Metadata url={url} />
|
|
111
|
-
<QueryClientProvider client={queryClient}>
|
|
112
|
-
<StaticRouter location={pathname}>
|
|
113
|
-
<App />
|
|
114
|
-
</StaticRouter>
|
|
115
|
-
</QueryClientProvider>
|
|
116
|
-
<template id="state" data-state={JSON.stringify(state)} />
|
|
117
|
-
</HelmetProvider>,
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
return { app, helmetContext, sheet, queryClient };
|
|
121
|
-
}
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
3. Update your `application.tsx` to hydrate on the client:
|
|
125
|
-
|
|
126
|
-
```tsx
|
|
127
|
-
import { HelmetProvider } from "@dr.pogodin/react-helmet";
|
|
128
|
-
import { hydrateRoot } from "react-dom/client";
|
|
129
|
-
import { BrowserRouter } from "react-router";
|
|
130
|
-
import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
|
|
131
|
-
import App from "@/App";
|
|
132
|
-
import Metadata from "@/components/Metadata";
|
|
133
|
-
|
|
134
|
-
const queryClient = new QueryClient();
|
|
135
|
-
|
|
136
|
-
const stateEl = document.getElementById("state");
|
|
137
|
-
const state = JSON.parse(stateEl?.dataset.state ?? "{}");
|
|
138
|
-
stateEl?.remove();
|
|
139
|
-
|
|
140
|
-
hydrateRoot(
|
|
141
|
-
document.getElementById("root")!,
|
|
142
|
-
<HelmetProvider>
|
|
143
|
-
<Metadata url={window.location.href} />
|
|
144
|
-
<QueryClientProvider client={queryClient}>
|
|
145
|
-
<Hydrate state={state}>
|
|
146
|
-
<BrowserRouter>
|
|
147
|
-
<App />
|
|
148
|
-
</BrowserRouter>
|
|
149
|
-
</Hydrate>
|
|
150
|
-
</QueryClientProvider>
|
|
151
|
-
</HelmetProvider>,
|
|
152
|
-
);
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
4. Create an SSR entry point at `app/frontend/ssr/ssr.ts`:
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
import { head, transform } from "@/ssr/utils";
|
|
159
|
-
import { renderToString } from "react-dom/server.node";
|
|
160
|
-
import { createServer } from "universal-renderer";
|
|
161
|
-
|
|
162
|
-
const app = await createServer({
|
|
163
|
-
setup: (await import("@/ssr/setup")).default,
|
|
164
|
-
|
|
165
|
-
render: ({ app, helmet, sheet }) => {
|
|
166
|
-
const root = renderToString(app);
|
|
167
|
-
const styles = sheet.getStyleTags();
|
|
168
|
-
return {
|
|
169
|
-
head: head({ helmet }),
|
|
170
|
-
body: `${root}\n${styles}`,
|
|
171
|
-
};
|
|
172
|
-
},
|
|
173
|
-
|
|
174
|
-
cleanup: ({ sheet, queryClient }) => {
|
|
175
|
-
sheet?.seal();
|
|
176
|
-
queryClient?.clear();
|
|
177
|
-
},
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
app.listen(3001);
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
5. Build the SSR bundle:
|
|
184
|
-
|
|
185
|
-
```bash
|
|
186
|
-
$ bin/vite build --ssr
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
6. Start your servers:
|
|
190
|
-
|
|
191
|
-
```Procfile
|
|
192
|
-
web: bin/rails s
|
|
193
|
-
ssr: bin/vite ssr
|
|
194
|
-
```
|
|
134
|
+
The gem never reads ENV itself; bind whatever keys you like in the initializer.
|
|
135
|
+
The suggested prefix is `UNIVERSAL_RENDERER_*`.
|
|
195
136
|
|
|
196
|
-
|
|
137
|
+
| Option | Default | Notes |
|
|
138
|
+
| -------------- | ---------------- | ---------------------------------------------------------------- |
|
|
139
|
+
| `url` | `nil` | Blank disables SSR entirely. |
|
|
140
|
+
| `timeout` | `3` | Open and read timeout, seconds. |
|
|
141
|
+
| `render_path` | `nil` | `nil` uses the path already in `url`. |
|
|
142
|
+
| `stream_path` | `"/stream"` | Must match the Node side. |
|
|
143
|
+
| `sanitize` | `true` | See below. |
|
|
144
|
+
| `scrubber` | `Scrubber.new` | Any `Loofah::Scrubber`. |
|
|
145
|
+
| `auto_include` | `true` | `false` to include `Renderable` per controller instead. |
|
|
146
|
+
| `on_error` | `nil` | `->(error, context) { ... }` |
|
|
147
|
+
|
|
148
|
+
### Sanitization
|
|
197
149
|
|
|
198
|
-
|
|
150
|
+
`ssr_head` and `ssr_body` run the render through Loofah by default.
|
|
199
151
|
|
|
200
|
-
|
|
152
|
+
Be clear about what that buys you. The bundled scrubber is a **blocklist**, and
|
|
153
|
+
a blocklist over the whole HTML grammar cannot be a boundary against
|
|
154
|
+
attacker-controlled markup: the sanitizer and the browser have to agree on how
|
|
155
|
+
the document parses, and elements that switch parsing context are how they stop
|
|
156
|
+
agreeing. The scrubber removes the ones that are known to do this (`noscript`,
|
|
157
|
+
the MathML integration points) and pins them with tests, but treat it as defense
|
|
158
|
+
in depth over HTML your own renderer produced — **escape untrusted data inside
|
|
159
|
+
the render**, which React already does unless you reach for
|
|
160
|
+
`dangerouslySetInnerHTML`.
|
|
201
161
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
162
|
+
It also parses and rewrites the whole document on the Rails side of every
|
|
163
|
+
request, which eats into the latency SSR is meant to buy. `c.sanitize = false`
|
|
164
|
+
is a reasonable trade once you are confident about what the renderer emits. It
|
|
165
|
+
defaults to on because the cost is bounded and the mistake it catches is not.
|
|
206
166
|
|
|
207
|
-
|
|
167
|
+
### Observability
|
|
208
168
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
```
|
|
169
|
+
Every failed render is a silent fallback, so without a signal you cannot tell a
|
|
170
|
+
healthy renderer from one that has been down for a week:
|
|
212
171
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
172
|
+
```ruby
|
|
173
|
+
ActiveSupport::Notifications.subscribe("render.universal_renderer") do |event|
|
|
174
|
+
StatsD.timing("ssr.duration", event.duration,
|
|
175
|
+
tags: ["outcome:#{event.payload[:outcome]}"])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
c.on_error = ->(error, context) { Sentry.capture_exception(error, extra: context) }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`outcome` is one of `:ok`, `:not_configured`, `:http_error`, `:timeout`, `:error`.
|
|
182
|
+
The notification fires even when no renderer URL is configured; `on_error` is
|
|
183
|
+
reserved for failures after a configured render is attempted.
|
|
184
|
+
|
|
185
|
+
## The renderer
|
|
186
|
+
|
|
187
|
+
### The render config
|
|
188
|
+
|
|
189
|
+
One module holds the whole render (`app/frontend/ssr/config.ts` by convention).
|
|
190
|
+
Both entry points load it.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import { dehydrate } from "@tanstack/react-query";
|
|
194
|
+
import { renderToString } from "react-dom/server";
|
|
195
|
+
import { StaticRouter } from "react-router";
|
|
196
|
+
import { ServerStyleSheet } from "styled-components";
|
|
197
|
+
import type { SsrConfig } from "universal-renderer";
|
|
198
|
+
import { hydrateReactQuery } from "universal-renderer/react-query";
|
|
199
|
+
|
|
200
|
+
import { setBrowserLocation } from "./globals";
|
|
201
|
+
import App from "@/App";
|
|
202
|
+
import { preloadRoute, queryClient } from "@/App";
|
|
203
|
+
|
|
204
|
+
export default {
|
|
205
|
+
setup: async (url, props) => {
|
|
206
|
+
const { pathname, search } = new URL(url);
|
|
207
|
+
setBrowserLocation(url);
|
|
208
|
+
|
|
209
|
+
queryClient.clear();
|
|
210
|
+
hydrateReactQuery(props, queryClient);
|
|
211
|
+
await preloadRoute(pathname);
|
|
212
|
+
|
|
213
|
+
const sheet = new ServerStyleSheet();
|
|
214
|
+
const app = sheet.collectStyles(
|
|
215
|
+
<StaticRouter location={`${pathname}${search}`}>
|
|
216
|
+
<App />
|
|
217
|
+
</StaticRouter>,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return { app, sheet, props, state: dehydrate(queryClient) };
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
prepare: (context) => {
|
|
224
|
+
context.previousFlags = { ...FEATURE_FLAGS };
|
|
225
|
+
Object.assign(FEATURE_FLAGS, context.props.feature_flags);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
render: ({ app, sheet, state }) => ({
|
|
229
|
+
body: renderToString(app),
|
|
230
|
+
head: sheet.getStyleTags(),
|
|
231
|
+
payload: { state },
|
|
232
|
+
}),
|
|
233
|
+
|
|
234
|
+
cleanup: ({ sheet, previousFlags }) => {
|
|
235
|
+
Object.assign(FEATURE_FLAGS, previousFlags);
|
|
236
|
+
sheet.seal();
|
|
237
|
+
queryClient.clear();
|
|
238
|
+
},
|
|
239
|
+
} satisfies SsrConfig<any>;
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The four hooks are not arbitrary:
|
|
243
|
+
|
|
244
|
+
- **`setup`** is async and must not touch module-level state. It awaits things —
|
|
245
|
+
a lazy route chunk, a fetch — and a mutation made before an await point stays
|
|
246
|
+
visible for as long as the await lasts.
|
|
247
|
+
- **`prepare`** is sync and runs immediately before the render, with no await in
|
|
248
|
+
between. This is where shared singletons get mutated.
|
|
249
|
+
- **`render`** produces the HTML and the hydration payload.
|
|
250
|
+
- **`cleanup`** always runs, and runs before the next render starts. Undo
|
|
251
|
+
`prepare` here.
|
|
252
|
+
|
|
253
|
+
### Concurrency
|
|
254
|
+
|
|
255
|
+
Renders are **serialized by default**. An app retrofitted with SSR keeps
|
|
256
|
+
request-scoped state in module-level singletons — a store, a query client, a
|
|
257
|
+
mutable feature-flag object, a CSS-in-JS registry — and two renders interleaving
|
|
258
|
+
through those is not a slow page, it is one visitor's data in another visitor's
|
|
259
|
+
HTML. Scale out with more renderer processes; `bin/web` starts one per web
|
|
260
|
+
process, so SSR capacity tracks your web dyno count.
|
|
261
|
+
|
|
262
|
+
Raise `concurrency` (or pass `"unbounded"`) only once you have verified the render
|
|
263
|
+
touches no shared mutable state.
|
|
264
|
+
|
|
265
|
+
The renderer admits at most ten waiting requests per concurrency slot by
|
|
266
|
+
default. It returns `503` when that queue is full and removes requests that
|
|
267
|
+
disconnect while waiting, so a Rails timeout cannot leave stale renders ahead
|
|
268
|
+
of live traffic. Configure `queueLimit` only if the default does not fit your
|
|
269
|
+
traffic and render latency.
|
|
270
|
+
|
|
271
|
+
### Render timeout and health
|
|
272
|
+
|
|
273
|
+
Serializing renders has a cost worth naming: a slot is never taken back from a
|
|
274
|
+
running render — it is still touching module state, and handing that slot on is
|
|
275
|
+
the interleaving `concurrency` exists to prevent — so **one render that never
|
|
276
|
+
settles ends the renderer**. At `concurrency: 1` the queue fills, everything
|
|
277
|
+
after it gets `503`, and Rails falls back to client rendering indefinitely.
|
|
278
|
+
|
|
279
|
+
Two separate settings bound it, because "over budget" and "never finishing" are
|
|
280
|
+
different questions:
|
|
281
|
+
|
|
282
|
+
- `renderTimeout` (default `2500`ms, `false` to disable) answers the caller with
|
|
283
|
+
`504` instead of hanging. For a streaming render it caps time to first byte,
|
|
284
|
+
not the whole response.
|
|
285
|
+
- `stallAfterMs` (default `30000`ms, `false` to disable) is how long one render
|
|
286
|
+
may hold its slot before `GET /health` returns `503` with
|
|
287
|
+
`{ status: "STALLED", renders: {...} }`.
|
|
288
|
+
|
|
289
|
+
Do not collapse these into one value. A streaming response holds its slot until
|
|
290
|
+
its last chunk, so a stall threshold near `renderTimeout` reports perfectly
|
|
291
|
+
healthy streams as stalled and invites a supervisor to restart the renderer
|
|
292
|
+
mid-response. Raise `stallAfterMs` above your slowest legitimate stream; the 30s
|
|
293
|
+
default is also roughly where Heroku's router and most ALB defaults have already
|
|
294
|
+
abandoned the request.
|
|
295
|
+
|
|
296
|
+
Nothing inside the process can clear a stuck render, so the health signal is the
|
|
297
|
+
point: the generated `bin/web` polls it and restarts the renderer without
|
|
298
|
+
touching the app server. Tune with `SSR_HEALTH_INTERVAL`, `SSR_HEALTH_FAILURES`,
|
|
299
|
+
or turn it off with `SSR_WATCHDOG=0`.
|
|
300
|
+
|
|
301
|
+
Development sets both to `false` — a breakpoint in the render outlasts any
|
|
302
|
+
production budget, and a `504` or a restart there is noise.
|
|
303
|
+
|
|
304
|
+
**Keep `c.timeout` above `renderTimeout`.** The defaults are 3s and 2.5s, so
|
|
305
|
+
the renderer gives up before Rails falls back. Disconnected requests are dropped
|
|
306
|
+
from the queue, but a render already running cannot be taken back; preserve that
|
|
307
|
+
ordering when tuning either timeout.
|
|
308
|
+
|
|
309
|
+
### Entry points
|
|
310
|
+
|
|
311
|
+
Production runs the prebuilt bundle:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
// app/frontend/ssr/server.ts
|
|
315
|
+
import "./globals";
|
|
316
|
+
|
|
317
|
+
const { default: config } = await import("./config");
|
|
318
|
+
const { startServer } = await import("universal-renderer");
|
|
319
|
+
|
|
320
|
+
await startServer(config);
|
|
321
|
+
```
|
|
217
322
|
|
|
218
|
-
|
|
323
|
+
Development loads the config through Vite, so the app graph gets the same
|
|
324
|
+
transforms the client dev server gives it and edits need no rebuild:
|
|
219
325
|
|
|
220
|
-
|
|
326
|
+
```ts
|
|
327
|
+
// app/frontend/ssr/dev.ts
|
|
328
|
+
import "./globals";
|
|
329
|
+
|
|
330
|
+
const { startDevServer } = await import("universal-renderer/dev");
|
|
331
|
+
|
|
332
|
+
await startDevServer({ entry: "app/frontend/ssr/config.ts" });
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
These must not be the same file. A Vite dev server transforming modules per render
|
|
336
|
+
is the single largest cost in the SSR path.
|
|
337
|
+
|
|
338
|
+
Note the **dynamic** imports. The app graph touches browser globals while its
|
|
339
|
+
modules evaluate, and static imports are all evaluated before the entry body runs,
|
|
340
|
+
so `./globals` would land too late.
|
|
341
|
+
|
|
342
|
+
### Browser globals
|
|
343
|
+
|
|
344
|
+
`renderToString` does not run effects, so `useEffect` is safe. It does still
|
|
345
|
+
evaluate every module in the graph and every render body, and a client-first app
|
|
346
|
+
reaches for `window`, `document`, `localStorage`, or `navigator` in both — a
|
|
347
|
+
singleton assigning `window.myThing` at module scope, a component reading
|
|
348
|
+
`window.innerWidth` while rendering. None of that survives under Node, and none
|
|
349
|
+
of it is React's problem to solve.
|
|
350
|
+
|
|
351
|
+
The package does not ship a shim for this, deliberately. Which globals a graph
|
|
352
|
+
touches is a property of that graph, not of SSR, so a library version would be
|
|
353
|
+
guesses: too small to boot your app and too large to reason about. The generator
|
|
354
|
+
scaffolds `app/frontend/ssr/globals.ts` in your app instead, as a starting point
|
|
355
|
+
you own, edit, and delete if it turns out you need none of it. It carries the
|
|
356
|
+
reasoning, including the two things that bite:
|
|
357
|
+
|
|
358
|
+
- Defining `window` makes `typeof window === "undefined"` false process-wide.
|
|
359
|
+
That check is how libraries detect a server, so they all take the browser
|
|
360
|
+
path. Prefer fixing the module that reaches for the DOM.
|
|
361
|
+
- Some libraries decide once, at module-evaluation time, whether they are in a
|
|
362
|
+
browser (`typeof window !== "undefined" ? null : {...}`). Imported after your
|
|
363
|
+
globals, such a library loses its server API for good.
|
|
364
|
+
|
|
365
|
+
### The SSR build
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
// vite.config.ssr.mts
|
|
369
|
+
import react from "@vitejs/plugin-react";
|
|
370
|
+
import { defineSsrConfig } from "universal-renderer/vite";
|
|
371
|
+
|
|
372
|
+
export default defineSsrConfig({
|
|
373
|
+
entry: "app/frontend/ssr/server.ts",
|
|
374
|
+
plugins: [react()],
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
`defineSsrConfig` exists because a Rails SSR build has four settings that are each
|
|
379
|
+
wrong by default and each fail *silently* — you get a bundle, it just renders the
|
|
380
|
+
wrong thing. Note in particular what is **absent**: `vite-plugin-rails`, which is
|
|
381
|
+
built for the client manifest pipeline and overrides entrypoints and outDir. List
|
|
382
|
+
only the plugins the render itself needs; your client build keeps using
|
|
383
|
+
`vite.config.mts` unchanged. See the function's docs for the other three.
|
|
384
|
+
|
|
385
|
+
The bundle lands at `ssr-build/server.mjs`, deliberately outside `public/` — it is
|
|
386
|
+
server code and must not be web-servable.
|
|
387
|
+
|
|
388
|
+
### Running it
|
|
389
|
+
|
|
390
|
+
```procfile
|
|
391
|
+
# Procfile.dev
|
|
392
|
+
web: bin/rails s
|
|
393
|
+
vite: bin/vite dev
|
|
394
|
+
ssr: bun app/frontend/ssr/dev.ts
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```procfile
|
|
398
|
+
# Procfile
|
|
399
|
+
web: bin/web
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`bin/web` runs the renderer alongside your app server on the same host rather than
|
|
403
|
+
as a separate process type, because PaaS process types get no routable address for
|
|
404
|
+
each other. If the renderer dies, requests fall back to client rendering; that is
|
|
405
|
+
a degraded page, not an outage, so it must not take the process down.
|
|
406
|
+
|
|
407
|
+
It defaults to Bun and Puma, and both are environment variables rather than edits:
|
|
408
|
+
|
|
409
|
+
| Variable | Default | Purpose |
|
|
410
|
+
| ------------- | ---------------------------------- | -------------------------- |
|
|
411
|
+
| `SSR_RUNTIME` | `bun` | Set to `node` if not Bun. |
|
|
412
|
+
| `WEB_CMD` | `bundle exec puma -C config/puma.rb` | App server command line. |
|
|
413
|
+
| `SSR_BUNDLE` | `ssr-build/server.mjs` | Built renderer entry. |
|
|
414
|
+
|
|
415
|
+
### Sorbet
|
|
416
|
+
|
|
417
|
+
The engine includes `Renderable` into `ActionController::Base` through
|
|
418
|
+
`ActiveSupport.on_load`, which happens at runtime and so is invisible to Sorbet.
|
|
419
|
+
Add a shim, or every `render_ssr` / `add_prop` call is an undefined-method error:
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
# sorbet/rbi/shims/universal_renderer.rbi
|
|
423
|
+
# typed: true
|
|
424
|
+
|
|
425
|
+
class ActionController::Base
|
|
426
|
+
include UniversalRenderer::Renderable
|
|
427
|
+
end
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Streaming
|
|
431
|
+
|
|
432
|
+
Streaming is opt-in per controller:
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
enable_ssr streaming: true
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
The layout emits `<!-- SSR_HEAD -->` / `<!-- SSR_BODY -->` markers (`ssr_head`
|
|
439
|
+
and `ssr_body` do this for you), Rails posts the rendered layout to the SSR
|
|
440
|
+
service as `template`, and the service streams the substituted document back.
|
|
441
|
+
Provide `streamCallbacks` in the render config to enable the endpoint. A failed
|
|
442
|
+
stream falls back to a normal blocking render.
|
|
443
|
+
|
|
444
|
+
## Development
|
|
445
|
+
|
|
446
|
+
```bash
|
|
447
|
+
bundle install && bun install
|
|
448
|
+
|
|
449
|
+
bundle exec rspec # gem
|
|
450
|
+
bundle exec rubocop
|
|
451
|
+
cd universal-renderer && bun run test && bunx tsc --noEmit
|
|
452
|
+
```
|
|
221
453
|
|
|
222
454
|
## License
|
|
223
455
|
|
|
224
|
-
|
|
456
|
+
MIT
|