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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 26d0a501f384d55462cb406c39a7018a145e26d8e0cb27d59e8837895b1bccb5
4
- data.tar.gz: 7e238d77c6b5985443dafe44eb0d63916e379be7db221d4d4ef97274467ed210
3
+ metadata.gz: 66a7c97479e5a19a14c90c30fc4e2c4a443df850f4feb78a3ce362ed844c9329
4
+ data.tar.gz: ec30ad10cace480af52901dd9c9e5212377b339884c5e53f1989211d98de350e
5
5
  SHA512:
6
- metadata.gz: '010672957f51d784464a0eec5c97fc85d71185437fd4e287b76b1e300af21f16625a9d6ee7674cafe0e5e96fafe85d8ec0ba641dfb8537fa1d4bd28d5a7fa9f7'
7
- data.tar.gz: 0b8cc2cd2a4439e7159a6cd97cc8f2b511069f766f4d8bbcea1a8929c2a222d9b34c9447a9193e782c6c3726d28dbfe1886d280ed55ace0835a773209193147c
6
+ metadata.gz: 6b6aaf0cf5262ad208c0ddeb765ad2b938925c8c161897c1443b51288b0c986697d0c57ad4397286f3a46d03908ed6ef6c1faeda678e96f8eee318f877df6a7e
7
+ data.tar.gz: 648177df24585900b57ee41a01fc4e056043ea4217bde65cc8bab040700b35a9b9f6b8d79903b5ebe75c442ef9b0b3cf31d347be17ab116e991269e065bfd1dc
data/README.md CHANGED
@@ -4,221 +4,453 @@
4
4
 
5
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)
6
6
 
7
- A streamlined solution for integrating Server-Side Rendering (SSR) into Rails applications.
7
+ Server-Side Rendering for Rails apps with a React front end.
8
8
 
9
9
  ## Overview
10
10
 
11
- UniversalRenderer helps you forward rendering requests to external SSR services, manage responses, and improve performance, SEO, and user experience for JavaScript-heavy frontends. It works seamlessly with the `universal-renderer` NPM package.
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
- ## Features
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
- - **Streaming SSR** support
16
- - **Configurable SSR server endpoint** and timeouts
17
- - **Simple API** for passing data between Rails and your SSR service
18
- - **Automatic fallback** to client-side rendering if SSR fails
19
- - **View helpers** for easy integration into your layouts
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
- 1. Add to your Gemfile:
32
+ ```ruby
33
+ # Gemfile
34
+ gem "universal_renderer"
35
+ ```
24
36
 
25
- ```ruby
26
- gem "universal_renderer"
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
- 2. Install:
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
- ```bash
32
- $ bundle install
33
- ```
47
+ ## Rendering from a controller
34
48
 
35
- 3. Run the generator:
49
+ There are two ways in, and they are equally supported.
36
50
 
37
- ```bash
38
- $ rails generate universal_renderer:install
39
- ```
51
+ **Declarative**, when the whole action is server-rendered:
40
52
 
41
- ## Configuration
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
- Configure in `config/initializers/universal_renderer.rb`:
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
- UniversalRenderer.configure do |c|
47
- c.url = "http://localhost:3001"
48
- c.timeout = 3
49
- c.stream_path = "/stream"
50
- c.http.pool_size = 5 # persistent Net::HTTP connections per SSR origin
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
- # Blocking SSR is the default. Enable streaming per controller only when needed:
53
- # enable_ssr streaming: true
79
+ render "common/js_only"
54
80
  end
55
81
  ```
56
82
 
57
- The gem itself never reads environment variables; it only exposes the plain
58
- configuration attributes above with sensible defaults. Binding those values to
59
- `ENV` is entirely up to you, in your own initializer, using whatever keys you
60
- like. If you want a convention, the suggested env-var prefix is
61
- `UNIVERSAL_RENDERER_*` (e.g. `UNIVERSAL_RENDERER_URL`):
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 = ENV.fetch("UNIVERSAL_RENDERER_TIMEOUT", 3).to_i
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
- ## Setting Up the SSR Server
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
- ## Development
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
- To contribute to this project:
150
+ `ssr_head` and `ssr_body` run the render through Loofah by default.
199
151
 
200
- 1. Clone the repository:
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
- ```bash
203
- git clone https://github.com/thaske/universal_renderer.git
204
- cd universal_renderer
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
- 2. Initialize and update submodules:
167
+ ### Observability
208
168
 
209
- ```bash
210
- git submodule update --init --recursive
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
- 3. Install dependencies:
214
- ```bash
215
- bundle install
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
- ## Contributing
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
- Contributions are welcome! Please follow the coding guidelines in the project documentation.
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
- Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
456
+ MIT