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,77 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module UniversalRenderer
|
|
4
|
+
# Scaffolds a working SSR setup: the initializer, the two renderer entry points,
|
|
5
|
+
# the SSR Vite build, the precompile hook, and the web-dyno launcher. Each file
|
|
6
|
+
# encodes something that is wrong by default and fails silently.
|
|
4
7
|
class InstallGenerator < Rails::Generators::Base
|
|
5
8
|
source_root File.expand_path("templates", __dir__)
|
|
6
9
|
|
|
10
|
+
class_option :frontend_dir,
|
|
11
|
+
type: :string,
|
|
12
|
+
default: "app/frontend",
|
|
13
|
+
desc: "Where your JavaScript lives (vite_rails' sourceCodeDir)"
|
|
14
|
+
|
|
15
|
+
class_option :skip_frontend,
|
|
16
|
+
type: :boolean,
|
|
17
|
+
default: false,
|
|
18
|
+
desc: "Only write the Ruby-side files"
|
|
19
|
+
|
|
20
|
+
class_option :skip_deploy,
|
|
21
|
+
type: :boolean,
|
|
22
|
+
default: false,
|
|
23
|
+
desc: "Skip bin/web and the assets:precompile hook"
|
|
24
|
+
|
|
7
25
|
def copy_initializer
|
|
8
26
|
template "initializer.rb", "config/initializers/universal_renderer.rb"
|
|
9
27
|
end
|
|
10
28
|
|
|
29
|
+
def copy_frontend
|
|
30
|
+
return if options[:skip_frontend]
|
|
31
|
+
|
|
32
|
+
template "ssr/globals.ts", "#{frontend_dir}/ssr/globals.ts"
|
|
33
|
+
template "ssr/config.ts", "#{frontend_dir}/ssr/config.ts"
|
|
34
|
+
template "ssr/server.ts", "#{frontend_dir}/ssr/server.ts"
|
|
35
|
+
template "ssr/dev.ts", "#{frontend_dir}/ssr/dev.ts"
|
|
36
|
+
template "vite.config.ssr.mts", "vite.config.ssr.mts"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def copy_deploy_files
|
|
40
|
+
return if options[:skip_deploy] || options[:skip_frontend]
|
|
41
|
+
|
|
42
|
+
template "ssr.rake", "lib/tasks/ssr.rake"
|
|
43
|
+
template "web", "bin/web"
|
|
44
|
+
chmod "bin/web", 0o755
|
|
45
|
+
end
|
|
46
|
+
|
|
11
47
|
def show_installation_notes
|
|
12
|
-
say_status "info", "Universal Renderer installed
|
|
48
|
+
say_status "info", "Universal Renderer installed."
|
|
13
49
|
say_status "note", "Next steps:"
|
|
50
|
+
if options[:skip_frontend]
|
|
51
|
+
say_status "", " 1. Review config/initializers/universal_renderer.rb"
|
|
52
|
+
say_status "", " 2. Connect your existing renderer to that endpoint"
|
|
53
|
+
say_status "", " 3. Opt a controller in with `enable_ssr` or `render_ssr`"
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
say_status "", " 1. bun add universal-renderer (or npm/yarn)"
|
|
58
|
+
say_status "", " 2. Fill in #{frontend_dir}/ssr/config.ts — the render itself"
|
|
59
|
+
say_status "", " 3. Add the renderer to Procfile.dev:"
|
|
60
|
+
say_status "", " ssr: bun #{frontend_dir}/ssr/dev.ts"
|
|
61
|
+
say_status "", " 4. Add a build script to package.json:"
|
|
14
62
|
say_status "",
|
|
15
|
-
"
|
|
16
|
-
say_status "", "
|
|
63
|
+
' "build:ssr": "vite -c vite.config.ssr.mts build"'
|
|
64
|
+
say_status "", " 5. Opt a controller in with `enable_ssr` or `render_ssr`"
|
|
65
|
+
return if options[:skip_deploy]
|
|
66
|
+
|
|
67
|
+
say_status "", " 6. Point Procfile's web process at bin/web, which runs"
|
|
68
|
+
say_status "", " the renderer alongside your app server"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def frontend_dir
|
|
74
|
+
options[:frontend_dir].delete_suffix("/")
|
|
17
75
|
end
|
|
18
76
|
end
|
|
19
77
|
end
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
UniversalRenderer.configure do |c|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
c.url = ENV.fetch("UNIVERSAL_RENDERER_URL", "http://localhost:3001")
|
|
5
|
+
|
|
6
|
+
# Keep this above the renderer's 2.5-second default.
|
|
6
7
|
c.timeout = 3
|
|
7
|
-
|
|
8
|
+
|
|
8
9
|
c.http.pool_size = 5
|
|
9
10
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
11
|
+
# c.sanitize = false
|
|
12
|
+
# c.scrubber = MyScrubber.new
|
|
13
|
+
# c.auto_include = false
|
|
14
|
+
# c.on_error = ->(error, context) { Sentry.capture_exception(error, extra: context) }
|
|
12
15
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The render lifecycle: setup may await; prepare mutates shared state immediately
|
|
2
|
+
// before rendering; cleanup restores it. With concurrency: 1, prepare through
|
|
3
|
+
// cleanup never overlap.
|
|
4
|
+
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
|
|
7
|
+
import type { SsrConfig } from "universal-renderer";
|
|
8
|
+
|
|
9
|
+
import { setBrowserLocation } from "./globals";
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
setup: async (url, props) => {
|
|
13
|
+
const { pathname, search } = new URL(url);
|
|
14
|
+
setBrowserLocation(url);
|
|
15
|
+
|
|
16
|
+
// hydrateReactQuery(props, queryClient);
|
|
17
|
+
// await preloadRoute(pathname);
|
|
18
|
+
|
|
19
|
+
const location = `${pathname}${search}`;
|
|
20
|
+
// const app = <StaticRouter location={location}><App /></StaticRouter>;
|
|
21
|
+
|
|
22
|
+
return { location, props, app: null as any };
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
prepare: (context) => {
|
|
26
|
+
// context.previousFlags = { ...FEATURE_FLAGS };
|
|
27
|
+
// Object.assign(FEATURE_FLAGS, context.props.feature_flags);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
render: (context) => ({
|
|
31
|
+
body: renderToString(context.app),
|
|
32
|
+
// head: context.sheet.getStyleTags(),
|
|
33
|
+
// payload: { queryCache: dehydrate(queryClient) },
|
|
34
|
+
}),
|
|
35
|
+
|
|
36
|
+
cleanup: (context) => {
|
|
37
|
+
// Object.assign(FEATURE_FLAGS, context.previousFlags);
|
|
38
|
+
// context.sheet?.seal();
|
|
39
|
+
},
|
|
40
|
+
} satisfies SsrConfig<any>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Development renderer entry. Loads the render config through a middleware-mode
|
|
2
|
+
// Vite server, so plugins and path aliases apply and edits need no rebuild.
|
|
3
|
+
//
|
|
4
|
+
// Production does not go through this file. See server.ts.
|
|
5
|
+
import "./globals";
|
|
6
|
+
|
|
7
|
+
const { startDevServer } = await import("universal-renderer/dev");
|
|
8
|
+
|
|
9
|
+
await startDevServer({ entry: "<%= frontend_dir %>/ssr/config.ts" });
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// App-owned browser stubs for SSR. Add only what your graph needs. Defining
|
|
2
|
+
// `window` makes browser-detection checks false process-wide; prefer fixing DOM
|
|
3
|
+
// access in the app where possible.
|
|
4
|
+
|
|
5
|
+
const noop = () => undefined;
|
|
6
|
+
const g = globalThis as any;
|
|
7
|
+
const MARKER = "__ssrBrowserGlobals";
|
|
8
|
+
|
|
9
|
+
export const isShimmed = (): boolean => g[MARKER] === true;
|
|
10
|
+
export const SSR_VIEWPORT = { width: 1024, height: 768 };
|
|
11
|
+
|
|
12
|
+
const makeLocation = (value: string) => {
|
|
13
|
+
const url = new URL(value);
|
|
14
|
+
return {
|
|
15
|
+
href: url.href,
|
|
16
|
+
protocol: url.protocol,
|
|
17
|
+
host: url.host,
|
|
18
|
+
hostname: url.hostname,
|
|
19
|
+
port: url.port,
|
|
20
|
+
pathname: url.pathname,
|
|
21
|
+
search: url.search,
|
|
22
|
+
hash: url.hash,
|
|
23
|
+
origin: url.origin,
|
|
24
|
+
assign: noop,
|
|
25
|
+
reload: noop,
|
|
26
|
+
replace: noop,
|
|
27
|
+
toString: () => url.href,
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const makeStorage = (): Storage =>
|
|
32
|
+
({
|
|
33
|
+
length: 0,
|
|
34
|
+
clear: noop,
|
|
35
|
+
getItem: () => null,
|
|
36
|
+
key: () => null,
|
|
37
|
+
removeItem: noop,
|
|
38
|
+
setItem: noop,
|
|
39
|
+
}) as unknown as Storage;
|
|
40
|
+
|
|
41
|
+
const makeElement = (): any => ({
|
|
42
|
+
style: {},
|
|
43
|
+
dataset: {},
|
|
44
|
+
children: [],
|
|
45
|
+
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
|
46
|
+
setAttribute: noop,
|
|
47
|
+
removeAttribute: noop,
|
|
48
|
+
getAttribute: () => null,
|
|
49
|
+
appendChild: noop,
|
|
50
|
+
removeChild: noop,
|
|
51
|
+
addEventListener: noop,
|
|
52
|
+
removeEventListener: noop,
|
|
53
|
+
querySelector: () => null,
|
|
54
|
+
querySelectorAll: () => [],
|
|
55
|
+
contains: () => false,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export function setBrowserLocation(url: string): void {
|
|
59
|
+
if (!isShimmed()) return;
|
|
60
|
+
|
|
61
|
+
const location = makeLocation(url);
|
|
62
|
+
g.window.location = location;
|
|
63
|
+
g.location = location;
|
|
64
|
+
if (g.document) g.document.URL = location.href;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function installBrowserGlobals(): boolean {
|
|
68
|
+
if (isShimmed()) return false;
|
|
69
|
+
if (typeof g.window !== "undefined" && typeof g.document !== "undefined") {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
g[MARKER] = true;
|
|
74
|
+
const location = makeLocation("http://localhost:3000/");
|
|
75
|
+
const win: any = {
|
|
76
|
+
location,
|
|
77
|
+
localStorage: makeStorage(),
|
|
78
|
+
sessionStorage: makeStorage(),
|
|
79
|
+
navigator: { userAgent: "ssr", language: "en-US", languages: ["en-US"] },
|
|
80
|
+
document: {
|
|
81
|
+
readyState: "complete",
|
|
82
|
+
title: "",
|
|
83
|
+
cookie: "",
|
|
84
|
+
URL: location.href,
|
|
85
|
+
documentElement: makeElement(),
|
|
86
|
+
body: makeElement(),
|
|
87
|
+
head: makeElement(),
|
|
88
|
+
hidden: false,
|
|
89
|
+
visibilityState: "visible",
|
|
90
|
+
createElement: () => makeElement(),
|
|
91
|
+
getElementById: () => null,
|
|
92
|
+
querySelector: () => null,
|
|
93
|
+
querySelectorAll: () => [],
|
|
94
|
+
addEventListener: noop,
|
|
95
|
+
removeEventListener: noop,
|
|
96
|
+
},
|
|
97
|
+
history: { pushState: noop, replaceState: noop, back: noop, go: noop },
|
|
98
|
+
matchMedia: (media: string) => ({
|
|
99
|
+
matches: false,
|
|
100
|
+
media,
|
|
101
|
+
addListener: noop,
|
|
102
|
+
removeListener: noop,
|
|
103
|
+
addEventListener: noop,
|
|
104
|
+
removeEventListener: noop,
|
|
105
|
+
}),
|
|
106
|
+
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
|
107
|
+
requestAnimationFrame: (_cb: () => void) => 0,
|
|
108
|
+
cancelAnimationFrame: noop,
|
|
109
|
+
innerWidth: SSR_VIEWPORT.width,
|
|
110
|
+
innerHeight: SSR_VIEWPORT.height,
|
|
111
|
+
scrollTo: noop,
|
|
112
|
+
addEventListener: noop,
|
|
113
|
+
removeEventListener: noop,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
win.window = win;
|
|
117
|
+
win.self = win;
|
|
118
|
+
win.top = win;
|
|
119
|
+
|
|
120
|
+
g.window = win;
|
|
121
|
+
for (const key of [
|
|
122
|
+
"document",
|
|
123
|
+
"navigator",
|
|
124
|
+
"location",
|
|
125
|
+
"history",
|
|
126
|
+
"localStorage",
|
|
127
|
+
"sessionStorage",
|
|
128
|
+
"matchMedia",
|
|
129
|
+
"getComputedStyle",
|
|
130
|
+
"requestAnimationFrame",
|
|
131
|
+
"cancelAnimationFrame",
|
|
132
|
+
"innerWidth",
|
|
133
|
+
"innerHeight",
|
|
134
|
+
]) {
|
|
135
|
+
if (!(key in g)) g[key] = win[key];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
installBrowserGlobals();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Production renderer entry. Renders from the bundle vite.config.ssr.mts builds,
|
|
2
|
+
// with no Vite server and no per-request module transform. Development uses
|
|
3
|
+
// dev.ts instead.
|
|
4
|
+
//
|
|
5
|
+
// Browser globals first. See globals.ts, and delete it if your graph needs none.
|
|
6
|
+
import "./globals";
|
|
7
|
+
|
|
8
|
+
// Dynamic, not static: static imports all evaluate before the entry body runs, so
|
|
9
|
+
// the import above would land after the app graph has already touched globals.
|
|
10
|
+
//
|
|
11
|
+
// A library that snapshots `typeof window` at module scope must be imported
|
|
12
|
+
// statically *above* `./globals`. Leave a comment saying so, or a formatter
|
|
13
|
+
// re-sorting the imports undoes it.
|
|
14
|
+
const { default: config } = await import("./config");
|
|
15
|
+
const { startServer } = await import("universal-renderer");
|
|
16
|
+
|
|
17
|
+
await startServer({
|
|
18
|
+
...config,
|
|
19
|
+
// Serialized on purpose. See config.ts.
|
|
20
|
+
concurrency: 1,
|
|
21
|
+
// Keep this below the gem's three-second default timeout: a running render
|
|
22
|
+
// holds its slot, so the renderer must give up before Rails falls back.
|
|
23
|
+
renderTimeout: 2_500,
|
|
24
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# No :environment dependency: bundling the frontend does not need Rails booted,
|
|
4
|
+
# and booting it during precompile only adds ways for the build to fail —
|
|
5
|
+
# credentials, a database that is not up yet.
|
|
6
|
+
# rubocop:disable Rails/RakeEnvironment
|
|
7
|
+
namespace :ssr do
|
|
8
|
+
desc "Build the standalone SSR bundle that bin/web runs (ssr-build/)"
|
|
9
|
+
task :build do
|
|
10
|
+
# Goes through the package script rather than invoking `vite` directly:
|
|
11
|
+
# node_modules/.bin is not on PATH during a deploy build, so a bare `vite`
|
|
12
|
+
# fails there while working fine in a shell with the toolchain loaded.
|
|
13
|
+
# Swap `bun run` for `npm run` / `yarn` to match your package manager.
|
|
14
|
+
sh "bun run build:ssr"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
# rubocop:enable Rails/RakeEnvironment
|
|
18
|
+
|
|
19
|
+
# bin/web runs the compiled bundle, so it has to exist by the time a release
|
|
20
|
+
# boots. Build it with the rest of the assets.
|
|
21
|
+
if Rake::Task.task_defined?("assets:precompile")
|
|
22
|
+
Rake::Task["assets:precompile"].enhance(["ssr:build"])
|
|
23
|
+
else
|
|
24
|
+
# No asset pipeline here, so define the task rather than skipping the build
|
|
25
|
+
# and shipping a release with no renderer.
|
|
26
|
+
Rake::Task.define_task("assets:precompile" => "ssr:build")
|
|
27
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import react from "@vitejs/plugin-react";
|
|
2
|
+
import { defineSsrConfig } from "universal-renderer/vite";
|
|
3
|
+
|
|
4
|
+
// Standalone SSR build. defineSsrConfig handles the four settings that are wrong
|
|
5
|
+
// by default for a Rails SSR build; read its docs before overriding them.
|
|
6
|
+
//
|
|
7
|
+
// Note what is absent: vite-plugin-rails, which targets the client manifest
|
|
8
|
+
// pipeline and overrides entrypoints and outDir. Your client build keeps using
|
|
9
|
+
// vite.config.mts, unchanged.
|
|
10
|
+
export default defineSsrConfig({
|
|
11
|
+
entry: "<%= frontend_dir %>/ssr/server.ts",
|
|
12
|
+
plugins: [react()],
|
|
13
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Boots the SSR renderer alongside the app server.
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
SSR_BUNDLE="${SSR_BUNDLE:-ssr-build/server.mjs}"
|
|
6
|
+
export SSR_PORT="${SSR_PORT:-3001}"
|
|
7
|
+
|
|
8
|
+
# Override these for non-Bun runtimes or non-Puma app servers.
|
|
9
|
+
SSR_RUNTIME="${SSR_RUNTIME:-bun}"
|
|
10
|
+
WEB_CMD="${WEB_CMD:-bundle exec puma -C config/puma.rb}"
|
|
11
|
+
|
|
12
|
+
# Set SSR_WATCHDOG=0 to disable health-based renderer restarts.
|
|
13
|
+
SSR_WATCHDOG="${SSR_WATCHDOG:-1}"
|
|
14
|
+
SSR_HEALTH_URL="${SSR_HEALTH_URL:-http://127.0.0.1:${SSR_PORT}/health}"
|
|
15
|
+
SSR_HEALTH_INTERVAL="${SSR_HEALTH_INTERVAL:-10}"
|
|
16
|
+
SSR_HEALTH_FAILURES="${SSR_HEALTH_FAILURES:-3}"
|
|
17
|
+
|
|
18
|
+
SSR_PID=""
|
|
19
|
+
APP_PID=""
|
|
20
|
+
shutting_down=0
|
|
21
|
+
health_failures=0
|
|
22
|
+
|
|
23
|
+
start_ssr() {
|
|
24
|
+
if [ ! -f "$SSR_BUNDLE" ]; then
|
|
25
|
+
echo "[web] $SSR_BUNDLE missing — serving client-rendered pages only." >&2
|
|
26
|
+
return
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
"$SSR_RUNTIME" "$SSR_BUNDLE" &
|
|
30
|
+
SSR_PID=$!
|
|
31
|
+
echo "[web] SSR renderer started (pid $SSR_PID, port $SSR_PORT)"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop_ssr() {
|
|
35
|
+
[ -n "$SSR_PID" ] || return 0
|
|
36
|
+
|
|
37
|
+
kill -TERM "$SSR_PID" 2>/dev/null || true
|
|
38
|
+
for _ in 1 2 3 4 5; do
|
|
39
|
+
kill -0 "$SSR_PID" 2>/dev/null || break
|
|
40
|
+
sleep 1
|
|
41
|
+
done
|
|
42
|
+
kill -KILL "$SSR_PID" 2>/dev/null || true
|
|
43
|
+
wait "$SSR_PID" 2>/dev/null || true
|
|
44
|
+
SSR_PID=""
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
check_ssr_health() {
|
|
48
|
+
[ "$SSR_WATCHDOG" = "1" ] || return 0
|
|
49
|
+
[ -n "$SSR_PID" ] || return 0
|
|
50
|
+
command -v curl >/dev/null 2>&1 || return 0
|
|
51
|
+
|
|
52
|
+
if curl -fsS --max-time 5 "$SSR_HEALTH_URL" >/dev/null 2>&1; then
|
|
53
|
+
health_failures=0
|
|
54
|
+
return 0
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
health_failures=$((health_failures + 1))
|
|
58
|
+
[ "$health_failures" -ge "$SSR_HEALTH_FAILURES" ] || return 0
|
|
59
|
+
|
|
60
|
+
echo "[web] SSR renderer unhealthy after ${health_failures} checks — restarting." >&2
|
|
61
|
+
stop_ssr
|
|
62
|
+
health_failures=0
|
|
63
|
+
start_ssr
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
on_signal() {
|
|
67
|
+
shutting_down=1
|
|
68
|
+
if [ -n "$APP_PID" ]; then kill -TERM "$APP_PID" 2>/dev/null || true; fi
|
|
69
|
+
}
|
|
70
|
+
trap on_signal INT TERM
|
|
71
|
+
trap 'if [ -n "$SSR_PID" ]; then kill -KILL "$SSR_PID" 2>/dev/null || true; fi' EXIT
|
|
72
|
+
|
|
73
|
+
start_ssr
|
|
74
|
+
|
|
75
|
+
# shellcheck disable=SC2086
|
|
76
|
+
$WEB_CMD &
|
|
77
|
+
APP_PID=$!
|
|
78
|
+
|
|
79
|
+
elapsed=0
|
|
80
|
+
while [ "$shutting_down" -eq 0 ] && kill -0 "$APP_PID" 2>/dev/null; do
|
|
81
|
+
sleep 1
|
|
82
|
+
elapsed=$((elapsed + 1))
|
|
83
|
+
if [ "$shutting_down" -eq 0 ] && [ "$elapsed" -ge "$SSR_HEALTH_INTERVAL" ]; then
|
|
84
|
+
elapsed=0
|
|
85
|
+
check_ssr_health
|
|
86
|
+
fi
|
|
87
|
+
done
|
|
88
|
+
|
|
89
|
+
wait "$APP_PID"
|
|
90
|
+
APP_STATUS=$?
|
|
91
|
+
APP_PID=""
|
|
92
|
+
|
|
93
|
+
stop_ssr
|
|
94
|
+
exit "$APP_STATUS"
|
|
@@ -16,6 +16,9 @@ module UniversalRenderer
|
|
|
16
16
|
# This is used for non-streaming SSR, where the entire payload is fetched
|
|
17
17
|
# before the main application view is rendered.
|
|
18
18
|
#
|
|
19
|
+
# Emits a `render.universal_renderer` notification for every attempt and
|
|
20
|
+
# routes failures through `config.on_error`.
|
|
21
|
+
#
|
|
19
22
|
# @param url [String] The URL of the page to render on the SSR server.
|
|
20
23
|
# This should typically be the `request.original_url` from the controller.
|
|
21
24
|
# @param props [Hash] A hash of props to be passed to the SSR service.
|
|
@@ -25,54 +28,106 @@ module UniversalRenderer
|
|
|
25
28
|
# (HTTP 2xx). Returns `nil` when the request fails or the SSR service is
|
|
26
29
|
# unreachable.
|
|
27
30
|
def self.call(url, props)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
request = Net::HTTP::Post.new(uri.request_uri)
|
|
36
|
-
request.body = { url: url, props: props }.to_json
|
|
37
|
-
request["Content-Type"] = "application/json"
|
|
38
|
-
|
|
39
|
-
response = HttpPool.request(uri, timeout, request)
|
|
40
|
-
|
|
41
|
-
if response.is_a?(Net::HTTPSuccess)
|
|
42
|
-
raw_data = JSON.parse(response.body).deep_symbolize_keys
|
|
43
|
-
|
|
44
|
-
# Map the keys we care about to the Struct. The Node service might
|
|
45
|
-
# send `:body_html` instead of `:body`; favour the latter if
|
|
46
|
-
# present but fall back gracefully.
|
|
47
|
-
UniversalRenderer::SSR::Response.new(
|
|
48
|
-
head: raw_data[:head],
|
|
49
|
-
body: raw_data[:body] || raw_data[:body_html],
|
|
50
|
-
body_attrs: raw_data[:body_attrs]
|
|
51
|
-
)
|
|
52
|
-
else
|
|
53
|
-
UniversalRenderer.log do |log|
|
|
54
|
-
log.error(
|
|
55
|
-
"SSR fetch request to #{ssr_url} failed: #{response.code} - #{response.message} (URL: #{url})"
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
nil
|
|
59
|
-
end
|
|
60
|
-
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
61
|
-
UniversalRenderer.log do |log|
|
|
62
|
-
log.error(
|
|
63
|
-
"SSR fetch request to #{ssr_url} timed out: #{e.class.name} - #{e.message} (URL: #{url})"
|
|
64
|
-
)
|
|
31
|
+
config = UniversalRenderer.config
|
|
32
|
+
ssr_url = config.url
|
|
33
|
+
|
|
34
|
+
Instrumentation.instrument(url: url, mode: :blocking) do |event|
|
|
35
|
+
if ssr_url.blank?
|
|
36
|
+
event[:outcome] = :not_configured
|
|
37
|
+
next nil
|
|
65
38
|
end
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
39
|
+
|
|
40
|
+
perform(ssr_url, config, url, props, event)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.perform(ssr_url, config, url, props, event)
|
|
45
|
+
# A nil render_path means the path in `url` is already the endpoint.
|
|
46
|
+
parsed = URI.parse(ssr_url)
|
|
47
|
+
uri =
|
|
48
|
+
if config.render_path.present?
|
|
49
|
+
URI.join(parsed, absolute_path(config.render_path))
|
|
50
|
+
else
|
|
51
|
+
parsed
|
|
72
52
|
end
|
|
73
|
-
|
|
53
|
+
|
|
54
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
55
|
+
request.body = { url: url, props: props }.to_json
|
|
56
|
+
request["Content-Type"] = "application/json"
|
|
57
|
+
|
|
58
|
+
response = HttpPool.request(uri, config.timeout, request)
|
|
59
|
+
|
|
60
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
61
|
+
event[:outcome] = :http_error
|
|
62
|
+
event[:status] = response.code.to_i
|
|
63
|
+
fail_render(
|
|
64
|
+
url,
|
|
65
|
+
uri,
|
|
66
|
+
event,
|
|
67
|
+
"responded with #{response.code} #{response.message}"
|
|
68
|
+
)
|
|
69
|
+
return nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
build_response(JSON.parse(response.body))
|
|
73
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
74
|
+
event[:outcome] = :timeout
|
|
75
|
+
event[:error] = e
|
|
76
|
+
fail_render(url, uri, event, "timed out: #{e.class.name} - #{e.message}", e)
|
|
77
|
+
nil
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
event[:outcome] = :error
|
|
80
|
+
event[:error] = e
|
|
81
|
+
fail_render(url, uri, event, "failed: #{e.class.name} - #{e.message}", e)
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# String keys on purpose: `payload` can be a large dehydrated query cache,
|
|
86
|
+
# and deep-symbolizing it would walk and re-allocate all of it per render.
|
|
87
|
+
#
|
|
88
|
+
# The three keys that reach a view helper are type-checked, so a renderer
|
|
89
|
+
# answering 200 with the wrong shape falls back like any other failed
|
|
90
|
+
# render instead of raising mid-layout. `perform` rescues the TypeError.
|
|
91
|
+
def self.build_response(data)
|
|
92
|
+
unless data.is_a?(Hash)
|
|
93
|
+
raise TypeError,
|
|
94
|
+
"SSR service returned #{data.class.name}, expected a JSON object"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
UniversalRenderer::SSR::Response.new(
|
|
98
|
+
head: string_or_nil(data["head"]),
|
|
99
|
+
body: string_or_nil(data["body"]),
|
|
100
|
+
body_attrs: (data["body_attrs"] if data["body_attrs"].is_a?(Hash)),
|
|
101
|
+
payload: data["payload"]
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.string_or_nil(value)
|
|
106
|
+
value if value.is_a?(String)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# URI.join replaces the base URL's last path segment when the joined path is
|
|
110
|
+
# relative, which would post renders to the wrong endpoint.
|
|
111
|
+
def self.absolute_path(path)
|
|
112
|
+
path = path.to_s
|
|
113
|
+
path.start_with?("/") ? path : "/#{path}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.fail_render(url, uri, event, message, error = nil)
|
|
117
|
+
target = uri ? uri.to_s : UniversalRenderer.config.url.to_s
|
|
118
|
+
|
|
119
|
+
UniversalRenderer.log do |log|
|
|
120
|
+
log.error("SSR fetch request to #{target} #{message} (URL: #{url})")
|
|
74
121
|
end
|
|
122
|
+
|
|
123
|
+
Instrumentation.report(
|
|
124
|
+
error || StandardError.new("SSR fetch request #{message}"),
|
|
125
|
+
event.merge(target: target)
|
|
126
|
+
)
|
|
75
127
|
end
|
|
128
|
+
|
|
129
|
+
private_class_method :perform, :build_response, :string_or_nil,
|
|
130
|
+
:fail_render, :absolute_path
|
|
76
131
|
end
|
|
77
132
|
end
|
|
78
133
|
end
|
|
@@ -2,7 +2,7 @@ module UniversalRenderer
|
|
|
2
2
|
module Client
|
|
3
3
|
class Stream
|
|
4
4
|
module ErrorLogger
|
|
5
|
-
def self.log_setup_error(error, target_uri_string)
|
|
5
|
+
def self.log_setup_error(error, target_uri_string, context = {})
|
|
6
6
|
backtrace_info = error.backtrace&.first || "No backtrace available"
|
|
7
7
|
UniversalRenderer.log do |log|
|
|
8
8
|
log.error(
|
|
@@ -10,17 +10,24 @@ module UniversalRenderer
|
|
|
10
10
|
"#{error.class.name} - #{error.message} at #{backtrace_info}"
|
|
11
11
|
)
|
|
12
12
|
end
|
|
13
|
+
report(error, target_uri_string, :setup, context)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
def self.log_connection_error(error, target_uri_string)
|
|
16
|
+
def self.log_connection_error(error, target_uri_string, context = {})
|
|
16
17
|
UniversalRenderer.log do |log|
|
|
17
18
|
log.error(
|
|
18
19
|
"SSR stream connection to #{target_uri_string} failed: #{error.class.name} - #{error.message}"
|
|
19
20
|
)
|
|
20
21
|
end
|
|
22
|
+
report(error, target_uri_string, :connection, context)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
def self.log_unexpected_error(
|
|
25
|
+
def self.log_unexpected_error(
|
|
26
|
+
error,
|
|
27
|
+
target_uri_string,
|
|
28
|
+
context_message,
|
|
29
|
+
context = {}
|
|
30
|
+
)
|
|
24
31
|
backtrace_info = error.backtrace&.first || "No backtrace available"
|
|
25
32
|
UniversalRenderer.log do |log|
|
|
26
33
|
log.error(
|
|
@@ -28,6 +35,21 @@ module UniversalRenderer
|
|
|
28
35
|
"#{error.class.name} - #{error.message} at #{backtrace_info}"
|
|
29
36
|
)
|
|
30
37
|
end
|
|
38
|
+
report(error, target_uri_string, :unexpected, context)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Streaming failures fall back to client-side rendering just as blocking
|
|
42
|
+
# ones do, so they need the same escape hatch to an exception tracker.
|
|
43
|
+
def self.report(error, target_uri_string, stage, context = {})
|
|
44
|
+
Instrumentation.report(
|
|
45
|
+
error,
|
|
46
|
+
context.merge(
|
|
47
|
+
mode: :streaming,
|
|
48
|
+
outcome: context.fetch(:outcome, :error),
|
|
49
|
+
stage: stage,
|
|
50
|
+
target: target_uri_string
|
|
51
|
+
)
|
|
52
|
+
)
|
|
31
53
|
end
|
|
32
54
|
end
|
|
33
55
|
end
|