react_on_rails_pro 17.0.0.rc.8 → 17.0.0.rc.9
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/CONTEXT.md +135 -0
- data/Gemfile.lock +3 -3
- data/app/helpers/react_on_rails_pro_helper.rb +8 -2
- data/docs/adr/0001-boot-seed-rolling-deploy-bundles.md +38 -0
- data/lib/react_on_rails_pro/configuration.rb +7 -7
- data/lib/react_on_rails_pro/rolling_deploy_adapters/http.rb +82 -39
- data/lib/react_on_rails_pro/stream_cache.rb +8 -1
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/sig/react_on_rails_pro/configuration.rbs +3 -3
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f76154cd40917f3cb62526a57ec696e6a9efedc600c99c62a8cdffd18afd30d5
|
|
4
|
+
data.tar.gz: 63a43241e6c89df22486834d03451a125282273fbf40ebd17efe2982316de860
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 41997c4eb40dbe7858f8ae734b11d4a9f12840eb022e94e00ead20ea6afe12be1949f840347379f46b7452f68b41b9c076737ea9c721fba0e772c4501f82806f
|
|
7
|
+
data.tar.gz: 34a525de9302065a00826c4042b41dcd82d8b97e3d5024200618b3f1eb62b04edb3924a3e4527e487484a7c05233225563ee26817159ed352249df4aed466bef
|
data/CONTEXT.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# React on Rails Pro — Rolling Deploy
|
|
2
|
+
|
|
3
|
+
How the Node Renderer bundle cache is warmed so that during a rolling deploy —
|
|
4
|
+
when old and new app versions run side by side — no SSR request pays the
|
|
5
|
+
cold-bundle penalty. Covers the seeding mechanism and how it interacts with the
|
|
6
|
+
deploy pipeline (build vs. release, staging-to-production promotion).
|
|
7
|
+
|
|
8
|
+
## Language
|
|
9
|
+
|
|
10
|
+
**Rolling deploy**:
|
|
11
|
+
The window where old (draining) and new app instances run side by side, both
|
|
12
|
+
sending SSR requests to the Node Renderer fleet.
|
|
13
|
+
|
|
14
|
+
**Draining bundle**:
|
|
15
|
+
The bundle hash that draining old Rails instances still request during the
|
|
16
|
+
overlap; the new renderer fleet must serve it warm.
|
|
17
|
+
_Avoid_: "old bundle" (old relative to what? — the draining bundle is defined by
|
|
18
|
+
what is live-and-draining in _this environment_, not by build order).
|
|
19
|
+
|
|
20
|
+
**Pre-seed**:
|
|
21
|
+
Staging previous bundle hashes into the new renderer cache before it serves
|
|
22
|
+
traffic, so **draining bundle** requests hit warm cache instead of the **410
|
|
23
|
+
fallback**.
|
|
24
|
+
_Avoid_: warmup (means the per-request lazy cache fill, the opposite of this).
|
|
25
|
+
|
|
26
|
+
**Pre-seed source**:
|
|
27
|
+
The deployment(s) the pre-seed pulls bundles from — resolved from that
|
|
28
|
+
environment's `rolling_deploy_previous_urls` (HTTP adapter) at the moment the
|
|
29
|
+
seed runs. The source is whatever environment's config is in effect _when and
|
|
30
|
+
where the seed executes_.
|
|
31
|
+
|
|
32
|
+
**Build-time seed**:
|
|
33
|
+
Pre-seed baked into the image during `assets:precompile`. Uses the _building_
|
|
34
|
+
environment's config and a _snapshot_ of its then-live bundle. Frozen into the
|
|
35
|
+
image layer.
|
|
36
|
+
|
|
37
|
+
**Release-time seed** (a.k.a. **boot seed**):
|
|
38
|
+
Pre-seed run by the _target_ environment's renderer container at container boot
|
|
39
|
+
via `rake react_on_rails_pro:pre_seed_renderer_cache`, resolving that
|
|
40
|
+
environment's _actually-live_ bundle at that moment. Readiness-gated.
|
|
41
|
+
_Avoid_: "runtime seed" (ambiguous with the per-request 410 path).
|
|
42
|
+
|
|
43
|
+
**Multi-source seed**:
|
|
44
|
+
A build-time seed whose **pre-seed source** is a _list_ of endpoints (the
|
|
45
|
+
built-in HTTP adapter's `rolling_deploy_previous_urls` accepts more than one).
|
|
46
|
+
Staging seeds from both staging and production so the promoted image is born
|
|
47
|
+
prod-ready.
|
|
48
|
+
|
|
49
|
+
**Promotion model**:
|
|
50
|
+
Production is deployed by promoting the _exact staging image_, not by building a
|
|
51
|
+
fresh production image. Consequence: the image's **build-time seed** used the
|
|
52
|
+
_staging_ pipeline's config and a snapshot taken at staging-build time — never
|
|
53
|
+
production's.
|
|
54
|
+
|
|
55
|
+
**410 fallback**:
|
|
56
|
+
The self-healing but slow per-request cold path: cache miss → `410 Gone` → Rails
|
|
57
|
+
ships the bundle to the renderer → retry, repeating per request until cached.
|
|
58
|
+
The thing pre-seeding exists to avoid.
|
|
59
|
+
|
|
60
|
+
**Seed correctness (R1)**:
|
|
61
|
+
The requirement that the new renderer fleet holds the exact **draining bundle**
|
|
62
|
+
for _this_ environment at _this_ release.
|
|
63
|
+
|
|
64
|
+
**Deploy ordering (R2)**:
|
|
65
|
+
The requirement that the new renderer fleet is live and cache-warm _before_ new
|
|
66
|
+
Rails takes traffic, and old renderers stay up until old Rails drains. Also
|
|
67
|
+
called **renderer-before-Rails**.
|
|
68
|
+
|
|
69
|
+
## Relationships
|
|
70
|
+
|
|
71
|
+
- A **rolling deploy** has one **draining bundle** per bundle kind (server, and
|
|
72
|
+
RSC when enabled) that the new renderer fleet must serve warm.
|
|
73
|
+
- **Pre-seed** eliminates the **410 fallback** for the **draining bundle**;
|
|
74
|
+
without it, every draining-bundle request pays the cold path.
|
|
75
|
+
- **Seed correctness (R1)** and **Deploy ordering (R2)** are independent and
|
|
76
|
+
_both_ required. R2 alone (a delay) cannot rescue a wrong seed; R1 alone
|
|
77
|
+
cannot rescue Rails cutting over before the renderer is warm.
|
|
78
|
+
- Under the **promotion model**, a **build-time seed** _cannot_ satisfy R1: it
|
|
79
|
+
runs in the staging pipeline against a staging snapshot, but production's
|
|
80
|
+
**draining bundle** is only known at the (later, sometimes-skipped) promotion.
|
|
81
|
+
- Fix: set staging's **pre-seed source** to **production**, so the promoted
|
|
82
|
+
image is born prod-ready (build-time seed = prod's live bundle). This bounds
|
|
83
|
+
the blast radius but goes stale across _two pending promotions_.
|
|
84
|
+
- The residual staleness window (two images built before either is promoted) is
|
|
85
|
+
closed by a **release-time seed** at promotion, which resolves production's
|
|
86
|
+
actually-live **draining bundle**.
|
|
87
|
+
- Staging seeding prod bundles means staging's _own_ rolling deploys cold-miss
|
|
88
|
+
(staging drains staging's-previous, which is not seeded). Accepted: staging
|
|
89
|
+
SSR performance during deploys is not a goal.
|
|
90
|
+
|
|
91
|
+
### Three layers of defense
|
|
92
|
+
|
|
93
|
+
Correctness is layered; each layer bounds the failure the prior one leaves open.
|
|
94
|
+
|
|
95
|
+
1. **Multi-source build-time seed** — the image is born prod-ready; failure floor
|
|
96
|
+
if the boot seed can't reach the live endpoint. (Correct for a single pending
|
|
97
|
+
promotion.)
|
|
98
|
+
2. **Boot seed (release-time)** — the correctness path: pulls the _actually-live_
|
|
99
|
+
**draining bundle** at container start, closing the two-pending-promotions
|
|
100
|
+
window. Subsumes layer 1's correctness; layer 1 remains only as a fallback.
|
|
101
|
+
3. **Deploy ordering (R2)** — renderer live+warm before Rails; readiness gates on
|
|
102
|
+
the boot seed _completing_ (not succeeding — a failed seed degrades to the 410
|
|
103
|
+
fallback rather than wedging the deploy).
|
|
104
|
+
|
|
105
|
+
**Interdependency:** the **boot seed** returns the correct **draining bundle**
|
|
106
|
+
_only because_ the renderer boots before Rails (R2). Before new Rails is live,
|
|
107
|
+
the environment's live endpoint is still served by the draining old pods, so it
|
|
108
|
+
advertises the draining hash. If Rails cut over first, the live endpoint would
|
|
109
|
+
advertise the _new_ hash and the boot seed would miss the very bundle it needs.
|
|
110
|
+
Layer 2 depends on Layer 3.
|
|
111
|
+
|
|
112
|
+
## Example dialogue
|
|
113
|
+
|
|
114
|
+
> **Dev:** "We promote the staging image to prod, and pre-seed is on. Why is
|
|
115
|
+
> prod SSR still slow after a rolling deploy?"
|
|
116
|
+
> **Domain expert:** "Because the **build-time seed** ran in the staging
|
|
117
|
+
> pipeline — the image holds staging's previous bundle, not prod's **draining
|
|
118
|
+
> bundle**. The seed was correct for the wrong environment."
|
|
119
|
+
> **Dev:** "So if staging's **pre-seed source** points at production, the
|
|
120
|
+
> promoted image is already prod-ready?"
|
|
121
|
+
> **Domain expert:** "For a single pending promotion, yes. Two pending
|
|
122
|
+
> promotions still go stale — that's why promotion also runs a **release-time
|
|
123
|
+
> seed** against prod's live bundle."
|
|
124
|
+
> **Dev:** "And that's enough?"
|
|
125
|
+
> **Domain expert:** "That's **R1**. You still need **R2** — the new renderer
|
|
126
|
+
> fleet live and warm before Rails cuts over — or you get the 410 storm anyway."
|
|
127
|
+
|
|
128
|
+
## Flagged ambiguities
|
|
129
|
+
|
|
130
|
+
- "old bundle" was used to mean both the build-predecessor and the live-draining
|
|
131
|
+
bundle — resolved: the term of record is **draining bundle**, defined by what
|
|
132
|
+
is live in the target environment, not by build order.
|
|
133
|
+
- "the delay" (R2) was initially read as a fix for the slow-SSR report — resolved:
|
|
134
|
+
the report is an R1 (wrong-seed) failure; the delay is a necessary but separate
|
|
135
|
+
R2 concern.
|
data/Gemfile.lock
CHANGED
|
@@ -9,7 +9,7 @@ GIT
|
|
|
9
9
|
PATH
|
|
10
10
|
remote: ..
|
|
11
11
|
specs:
|
|
12
|
-
react_on_rails (17.0.0.rc.
|
|
12
|
+
react_on_rails (17.0.0.rc.9)
|
|
13
13
|
addressable
|
|
14
14
|
connection_pool
|
|
15
15
|
execjs (~> 2.5)
|
|
@@ -20,14 +20,14 @@ PATH
|
|
|
20
20
|
PATH
|
|
21
21
|
remote: .
|
|
22
22
|
specs:
|
|
23
|
-
react_on_rails_pro (17.0.0.rc.
|
|
23
|
+
react_on_rails_pro (17.0.0.rc.9)
|
|
24
24
|
async (>= 2.29)
|
|
25
25
|
async-http (~> 0.95)
|
|
26
26
|
execjs (~> 2.9)
|
|
27
27
|
io-endpoint (~> 0.17.0)
|
|
28
28
|
jwt (>= 2.5, < 4)
|
|
29
29
|
nokogiri (>= 1.12, < 2)
|
|
30
|
-
react_on_rails (= 17.0.0.rc.
|
|
30
|
+
react_on_rails (= 17.0.0.rc.9)
|
|
31
31
|
|
|
32
32
|
GEM
|
|
33
33
|
remote: https://rubygems.org/
|
|
@@ -1260,8 +1260,14 @@ module ReactOnRailsProHelper
|
|
|
1260
1260
|
render_options = create_render_options(react_component_name, options)
|
|
1261
1261
|
json_stream = server_rendered_react_component(render_options)
|
|
1262
1262
|
json_stream.transform do |chunk|
|
|
1263
|
-
html
|
|
1264
|
-
|
|
1263
|
+
# Read `html` without removing it. This chunk may be owned by StreamCache,
|
|
1264
|
+
# which buffers a reference to it and writes it to Rails.cache after the
|
|
1265
|
+
# stream completes. Mutating it here (e.g. `chunk.delete("html")`) would
|
|
1266
|
+
# tear the payload out of the buffered Hash, so prerender caching would
|
|
1267
|
+
# persist an empty payload and every cache hit would serve zero bytes.
|
|
1268
|
+
# See https://github.com/shakacode/react_on_rails/issues/4550.
|
|
1269
|
+
html = chunk["html"] || ""
|
|
1270
|
+
metadata = chunk.except("html").to_json
|
|
1265
1271
|
content_bytes = html.bytesize.to_s(16).rjust(8, "0")
|
|
1266
1272
|
"#{metadata}\t#{content_bytes}\n#{html}".html_safe
|
|
1267
1273
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Seed rolling-deploy bundles at renderer boot, not only at build
|
|
2
|
+
|
|
3
|
+
**Status:** accepted
|
|
4
|
+
|
|
5
|
+
When the production image is produced by **promoting the staging image**
|
|
6
|
+
(`upstream: hichee-staging` in Control Plane), a build-time pre-seed cannot know
|
|
7
|
+
production's live **draining bundle**: it runs in the staging pipeline against a
|
|
8
|
+
staging snapshot, and promotion happens later and is sometimes skipped. We
|
|
9
|
+
therefore have the **node-renderer container pull the target environment's
|
|
10
|
+
actually-live bundle at boot** (`rake react_on_rails_pro:pre_seed_renderer_cache`,
|
|
11
|
+
readiness-gated) as the correctness path, and keep the build-time seed as a
|
|
12
|
+
fallback floor.
|
|
13
|
+
|
|
14
|
+
## Considered options
|
|
15
|
+
|
|
16
|
+
- **Build-time seed only (status quo).** Rejected: structurally wrong under the
|
|
17
|
+
promotion model — the image holds staging's previous bundle, and even a
|
|
18
|
+
multi-source build-time seed (staging + production) goes stale across two
|
|
19
|
+
pending promotions.
|
|
20
|
+
- **Build a dedicated production image instead of promoting staging.** Rejected:
|
|
21
|
+
fights the promotion model (the point of promotion is shipping the exact
|
|
22
|
+
artifact tested on staging) and still goes stale if a prod image is ever built
|
|
23
|
+
ahead of its release.
|
|
24
|
+
- **Boot seed only.** Rejected in favor of belt-and-suspenders: a boot seed that
|
|
25
|
+
can't reach the live endpoint would fall all the way back to the per-request
|
|
26
|
+
410 path. Keeping the build-time multi-source seed bounds that failure to a
|
|
27
|
+
small, rare miss.
|
|
28
|
+
|
|
29
|
+
## Consequences
|
|
30
|
+
|
|
31
|
+
- Correctness of the boot seed **depends on deploy ordering (R2)**: the renderer
|
|
32
|
+
must boot before Rails, so the environment's live endpoint still advertises the
|
|
33
|
+
draining hash. If Rails cut over first, the boot seed would fetch the new hash
|
|
34
|
+
and miss the draining one.
|
|
35
|
+
- Readiness gates on the boot seed **completing, not succeeding** — a failed seed
|
|
36
|
+
degrades to the 410 fallback rather than wedging the deploy.
|
|
37
|
+
- Staging seeds production bundles, so staging's own rolling deploys cold-miss.
|
|
38
|
+
Accepted: staging SSR performance during deploys is not a goal.
|
|
@@ -38,7 +38,7 @@ module ReactOnRailsPro
|
|
|
38
38
|
remote_bundle_cache_adapter: Configuration::DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER,
|
|
39
39
|
rolling_deploy_adapter: Configuration::DEFAULT_ROLLING_DEPLOY_ADAPTER,
|
|
40
40
|
rolling_deploy_token: Configuration::DEFAULT_ROLLING_DEPLOY_TOKEN,
|
|
41
|
-
|
|
41
|
+
rolling_deploy_previous_urls: Configuration::DEFAULT_ROLLING_DEPLOY_PREVIOUS_URLS,
|
|
42
42
|
rolling_deploy_mount_path: Configuration::DEFAULT_ROLLING_DEPLOY_MOUNT_PATH,
|
|
43
43
|
ssr_timeout: Configuration::DEFAULT_SSR_TIMEOUT,
|
|
44
44
|
ssr_pre_hook_js: nil,
|
|
@@ -79,7 +79,7 @@ module ReactOnRailsPro
|
|
|
79
79
|
DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER = nil
|
|
80
80
|
DEFAULT_ROLLING_DEPLOY_ADAPTER = nil
|
|
81
81
|
DEFAULT_ROLLING_DEPLOY_TOKEN = nil
|
|
82
|
-
|
|
82
|
+
DEFAULT_ROLLING_DEPLOY_PREVIOUS_URLS = nil
|
|
83
83
|
DEFAULT_ROLLING_DEPLOY_MOUNT_PATH = "/react_on_rails_pro/rolling_deploy"
|
|
84
84
|
# Minimum bearer-token length when using the built-in HTTP rolling-deploy adapter.
|
|
85
85
|
# 32 chars matches SecureRandom.hex(16) and rules out obviously low-entropy values
|
|
@@ -112,7 +112,7 @@ module ReactOnRailsPro
|
|
|
112
112
|
:renderer_http_pool_timeout, :renderer_http_pool_warn_timeout,
|
|
113
113
|
:dependency_globs, :excluded_dependency_globs, :rendering_returns_promises,
|
|
114
114
|
:remote_bundle_cache_adapter, :rolling_deploy_adapter,
|
|
115
|
-
:rolling_deploy_token, :
|
|
115
|
+
:rolling_deploy_token, :rolling_deploy_previous_urls, :rolling_deploy_mount_path,
|
|
116
116
|
:ssr_pre_hook_js, :assets_to_copy,
|
|
117
117
|
:renderer_request_retry_limit, :throw_js_errors, :ssr_timeout,
|
|
118
118
|
:profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support,
|
|
@@ -201,7 +201,7 @@ module ReactOnRailsPro
|
|
|
201
201
|
tracing: nil,
|
|
202
202
|
dependency_globs: nil, excluded_dependency_globs: nil, rendering_returns_promises: nil,
|
|
203
203
|
remote_bundle_cache_adapter: nil, rolling_deploy_adapter: nil,
|
|
204
|
-
rolling_deploy_token: nil,
|
|
204
|
+
rolling_deploy_token: nil, rolling_deploy_previous_urls: nil,
|
|
205
205
|
rolling_deploy_mount_path: nil,
|
|
206
206
|
ssr_pre_hook_js: nil, assets_to_copy: nil,
|
|
207
207
|
renderer_request_retry_limit: nil, throw_js_errors: nil, ssr_timeout: nil,
|
|
@@ -229,7 +229,7 @@ module ReactOnRailsPro
|
|
|
229
229
|
self.remote_bundle_cache_adapter = remote_bundle_cache_adapter
|
|
230
230
|
self.rolling_deploy_adapter = rolling_deploy_adapter
|
|
231
231
|
self.rolling_deploy_token = rolling_deploy_token
|
|
232
|
-
self.
|
|
232
|
+
self.rolling_deploy_previous_urls = rolling_deploy_previous_urls
|
|
233
233
|
# Constructor nil/blank means "use the default"; configure-block assignment
|
|
234
234
|
# can still set nil/blank later to opt out of the engine auto-mount.
|
|
235
235
|
self.rolling_deploy_mount_path = rolling_deploy_mount_path.presence || DEFAULT_ROLLING_DEPLOY_MOUNT_PATH
|
|
@@ -407,8 +407,8 @@ module ReactOnRailsPro
|
|
|
407
407
|
|
|
408
408
|
# Only fires when the user has selected the built-in HTTP adapter. Custom
|
|
409
409
|
# adapters that re-use the new config knobs for their own reasons are not
|
|
410
|
-
# forced through this validation. We do not validate
|
|
411
|
-
# an unset
|
|
410
|
+
# forced through this validation. We do not validate previous_urls here:
|
|
411
|
+
# an unset list is a valid "discovery off; this is the upload-only side of
|
|
412
412
|
# a one-way deploy" mode (the adapter returns [] for previous_bundle_hashes).
|
|
413
413
|
def validate_rolling_deploy_http_adapter_config
|
|
414
414
|
return unless rolling_deploy_http_adapter?
|
|
@@ -38,11 +38,19 @@ module ReactOnRailsPro
|
|
|
38
38
|
# Configuration (see docs/pro/rolling-deploy-adapters.md):
|
|
39
39
|
#
|
|
40
40
|
# ReactOnRailsPro.configure do |config|
|
|
41
|
-
# config.rolling_deploy_adapter
|
|
42
|
-
# config.rolling_deploy_token
|
|
43
|
-
# config.
|
|
41
|
+
# config.rolling_deploy_adapter = ReactOnRailsPro::RollingDeployAdapters::Http
|
|
42
|
+
# config.rolling_deploy_token = ENV.fetch("ROLLING_DEPLOY_TOKEN")
|
|
43
|
+
# config.rolling_deploy_previous_urls = ENV["ROLLING_DEPLOY_PREVIOUS_URLS"]
|
|
44
44
|
# end
|
|
45
45
|
#
|
|
46
|
+
# `rolling_deploy_previous_urls` accepts a single URL string, a comma-
|
|
47
|
+
# separated string, or an Array of URL strings. Discovery unions the bundle
|
|
48
|
+
# hashes advertised by every endpoint; fetch tries each endpoint in order and
|
|
49
|
+
# returns the first that has the requested hash. Seeding from more than one
|
|
50
|
+
# endpoint (e.g. staging + production) lets an image built in one environment
|
|
51
|
+
# and promoted to another carry the promotion target's draining bundle. See
|
|
52
|
+
# docs/pro/rolling-deploy-adapters.md#promotion-deploys-need-a-release-time-boot-seed.
|
|
53
|
+
#
|
|
46
54
|
# Error contract matches the rolling_deploy_adapter protocol: every
|
|
47
55
|
# exception is caught and reported as a warning so a failed seed degrades
|
|
48
56
|
# to the runtime 410-retry fallback rather than failing the build.
|
|
@@ -81,19 +89,59 @@ module ReactOnRailsPro
|
|
|
81
89
|
|
|
82
90
|
class << self
|
|
83
91
|
def previous_bundle_hashes
|
|
84
|
-
|
|
85
|
-
return [] if
|
|
92
|
+
bases = configured_previous_urls
|
|
93
|
+
return [] if bases.empty?
|
|
86
94
|
|
|
87
95
|
if token_missing?
|
|
88
96
|
return warn_and_return("rolling_deploy_token is not configured; skipping manifest fetch",
|
|
89
97
|
[])
|
|
90
98
|
end
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
# Union across every configured endpoint. A single endpoint failing
|
|
101
|
+
# (down, 404, malformed manifest) yields [] for that base and does not
|
|
102
|
+
# abort discovery for the others — the point of multiple endpoints is
|
|
103
|
+
# resilience, so one unreachable source must not blank the rest.
|
|
104
|
+
bases.flat_map { |base| manifest_hashes(base) }.uniq
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fetch(bundle_hash)
|
|
108
|
+
return nil if hash_invalid?(bundle_hash)
|
|
109
|
+
|
|
110
|
+
bases = configured_previous_urls
|
|
111
|
+
return nil if bases.empty?
|
|
112
|
+
|
|
113
|
+
if token_missing?
|
|
114
|
+
return warn_and_return("rolling_deploy_token is not configured; skipping fetch(#{bundle_hash.inspect})",
|
|
115
|
+
nil)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Try each endpoint in order; return the first that has the hash. A
|
|
119
|
+
# given hash is content-addressed, so whichever endpoint serves it
|
|
120
|
+
# returns identical bytes — first hit wins.
|
|
121
|
+
bases.each do |base|
|
|
122
|
+
payload = fetch_from(base, bundle_hash)
|
|
123
|
+
return payload if payload
|
|
124
|
+
end
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Intentional no-op. The running Rails server IS the artifact store —
|
|
129
|
+
# bundle + companion assets are already on local disk where the
|
|
130
|
+
# mountable BundlesController will serve them on the next deploy's
|
|
131
|
+
# build CI. Documented in docs/pro/rolling-deploy-adapters.md.
|
|
132
|
+
def upload(_bundle_hash, bundle:, assets:)
|
|
133
|
+
# See class doc above.
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# One endpoint's manifest hashes, or [] on any failure so a single bad
|
|
139
|
+
# endpoint cannot abort discovery across the others.
|
|
140
|
+
def manifest_hashes(base)
|
|
141
|
+
response = http_get(URI("#{base}/manifest"), read_timeout: MANIFEST_READ_TIMEOUT_SECONDS)
|
|
142
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
143
|
+
return warn_and_return("manifest at #{base} returned HTTP #{response.code}", [])
|
|
144
|
+
end
|
|
97
145
|
|
|
98
146
|
parsed = JSON.parse(response.body)
|
|
99
147
|
# Filter manifest hashes through SAFE_HASH_PATTERN before returning
|
|
@@ -107,19 +155,13 @@ module ReactOnRailsPro
|
|
|
107
155
|
.reject(&:empty?)
|
|
108
156
|
.grep(ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN)
|
|
109
157
|
rescue StandardError => e
|
|
110
|
-
warn_and_return("previous_bundle_hashes failed: #{e.class}: #{e.message}", [])
|
|
158
|
+
warn_and_return("previous_bundle_hashes for #{base} failed: #{e.class}: #{e.message}", [])
|
|
111
159
|
end
|
|
112
160
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if token_missing?
|
|
119
|
-
return warn_and_return("rolling_deploy_token is not configured; skipping fetch(#{bundle_hash.inspect})",
|
|
120
|
-
nil)
|
|
121
|
-
end
|
|
122
|
-
|
|
161
|
+
# Fetch one hash from one endpoint. Returns the payload Hash or nil.
|
|
162
|
+
# Owns the temp dir lifecycle so a failed attempt cleans up before the
|
|
163
|
+
# caller tries the next endpoint into the same (hash-keyed) directory.
|
|
164
|
+
def fetch_from(base, bundle_hash)
|
|
123
165
|
dir = bundle_dir(bundle_hash)
|
|
124
166
|
FileUtils.mkdir_p(dir)
|
|
125
167
|
|
|
@@ -131,36 +173,37 @@ module ReactOnRailsPro
|
|
|
131
173
|
result
|
|
132
174
|
rescue StandardError => e
|
|
133
175
|
cleanup_and_return(dir, nil) if dir
|
|
134
|
-
warn_and_return("fetch(#{bundle_hash.inspect}) failed: #{e.class}: #{e.message}", nil)
|
|
176
|
+
warn_and_return("fetch(#{bundle_hash.inspect}) from #{base} failed: #{e.class}: #{e.message}", nil)
|
|
135
177
|
end
|
|
136
178
|
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
def
|
|
142
|
-
|
|
179
|
+
# Normalize the configured previous URL(s) into a de-duplicated list of
|
|
180
|
+
# scheme-validated base URLs. Accepts a single string, a comma-separated
|
|
181
|
+
# string, or an Array (whose entries may themselves be comma-separated —
|
|
182
|
+
# convenient for `ENV["ROLLING_DEPLOY_PREVIOUS_URLS"]`).
|
|
183
|
+
def configured_previous_urls
|
|
184
|
+
raw = ReactOnRailsPro.configuration.rolling_deploy_previous_urls
|
|
185
|
+
Array(raw)
|
|
186
|
+
.flat_map { |entry| entry.to_s.split(",") }
|
|
187
|
+
.map(&:strip)
|
|
188
|
+
.reject(&:empty?)
|
|
189
|
+
.uniq
|
|
190
|
+
.filter_map { |url| normalize_previous_url(url) }
|
|
143
191
|
end
|
|
144
192
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def configured_previous_url
|
|
148
|
-
url = ReactOnRailsPro.configuration.rolling_deploy_previous_url.to_s.strip
|
|
149
|
-
return nil if url.empty?
|
|
150
|
-
|
|
193
|
+
def normalize_previous_url(url)
|
|
151
194
|
uri = URI.parse(url.chomp("/"))
|
|
152
195
|
unless %w[http https].include?(uri.scheme)
|
|
153
196
|
Rails.logger.warn(
|
|
154
|
-
"#{LOG_PREFIX}
|
|
155
|
-
"#{uri.scheme.inspect}; expected http or https. Skipping
|
|
197
|
+
"#{LOG_PREFIX} rolling_deploy_previous_urls entry #{url.inspect} has unsupported scheme " \
|
|
198
|
+
"#{uri.scheme.inspect}; expected http or https. Skipping this entry."
|
|
156
199
|
)
|
|
157
200
|
return nil
|
|
158
201
|
end
|
|
159
202
|
uri.to_s
|
|
160
203
|
rescue URI::InvalidURIError => e
|
|
161
204
|
Rails.logger.warn(
|
|
162
|
-
"#{LOG_PREFIX}
|
|
163
|
-
"Skipping
|
|
205
|
+
"#{LOG_PREFIX} rolling_deploy_previous_urls entry #{url.inspect} is not a valid URI: " \
|
|
206
|
+
"#{e.message}. Skipping this entry."
|
|
164
207
|
)
|
|
165
208
|
nil
|
|
166
209
|
end
|
|
@@ -330,7 +373,7 @@ module ReactOnRailsPro
|
|
|
330
373
|
# by surfacing the cleartext-token risk in build CI logs.
|
|
331
374
|
#
|
|
332
375
|
# Loopback hosts are intentionally exempt so developers running a
|
|
333
|
-
# local Rails server for `
|
|
376
|
+
# local Rails server for `rolling_deploy_previous_urls` during
|
|
334
377
|
# development don't see noise on every build CI rehearsal — the
|
|
335
378
|
# token never leaves the host in that case.
|
|
336
379
|
LOOPBACK_HOST_PATTERN = /\A(localhost|127(?:\.\d{1,3}){3}|::1|\[::1\])\z/
|
|
@@ -65,7 +65,14 @@ module ReactOnRailsPro
|
|
|
65
65
|
|
|
66
66
|
buffered_chunks = []
|
|
67
67
|
@upstream_stream.each_chunk do |chunk|
|
|
68
|
-
|
|
68
|
+
# Snapshot the chunk before handing it downstream. A downstream consumer
|
|
69
|
+
# receives the same object we buffer here, and this buffered array is
|
|
70
|
+
# persisted to Rails.cache after the stream completes. If a consumer
|
|
71
|
+
# mutates its chunk (e.g. deleting a key while framing), an un-duped
|
|
72
|
+
# buffer would persist that mutation and serve a corrupted cache entry.
|
|
73
|
+
# A shallow dup keeps the cached copy intact regardless of the consumer.
|
|
74
|
+
# See https://github.com/shakacode/react_on_rails/issues/4550.
|
|
75
|
+
buffered_chunks << chunk.dup
|
|
69
76
|
yield(chunk)
|
|
70
77
|
end
|
|
71
78
|
Rails.cache.write(@cache_key, buffered_chunks, @cache_options || {})
|
|
@@ -34,7 +34,7 @@ module ReactOnRailsPro
|
|
|
34
34
|
DEFAULT_REMOTE_BUNDLE_CACHE_ADAPTER: nil
|
|
35
35
|
DEFAULT_ROLLING_DEPLOY_ADAPTER: nil
|
|
36
36
|
DEFAULT_ROLLING_DEPLOY_TOKEN: nil
|
|
37
|
-
|
|
37
|
+
DEFAULT_ROLLING_DEPLOY_PREVIOUS_URLS: nil
|
|
38
38
|
DEFAULT_ROLLING_DEPLOY_MOUNT_PATH: String
|
|
39
39
|
DEFAULT_RENDERER_REQUEST_RETRY_LIMIT: Integer
|
|
40
40
|
DEFAULT_THROW_JS_ERRORS: bool
|
|
@@ -70,7 +70,7 @@ module ReactOnRailsPro
|
|
|
70
70
|
attr_accessor remote_bundle_cache_adapter: Module?
|
|
71
71
|
attr_accessor rolling_deploy_adapter: Module?
|
|
72
72
|
attr_accessor rolling_deploy_token: String?
|
|
73
|
-
attr_accessor
|
|
73
|
+
attr_accessor rolling_deploy_previous_urls: (String | Array[String])?
|
|
74
74
|
attr_accessor rolling_deploy_mount_path: String?
|
|
75
75
|
attr_accessor ssr_pre_hook_js: String?
|
|
76
76
|
attr_accessor assets_to_copy: Array[String]?
|
|
@@ -106,7 +106,7 @@ module ReactOnRailsPro
|
|
|
106
106
|
?remote_bundle_cache_adapter: Module?,
|
|
107
107
|
?rolling_deploy_adapter: Module?,
|
|
108
108
|
?rolling_deploy_token: String?,
|
|
109
|
-
?
|
|
109
|
+
?rolling_deploy_previous_urls: (String | Array[String])?,
|
|
110
110
|
?rolling_deploy_mount_path: String?,
|
|
111
111
|
?ssr_pre_hook_js: String?,
|
|
112
112
|
?assets_to_copy: Array[String]?,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: react_on_rails_pro
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 17.0.0.rc.
|
|
4
|
+
version: 17.0.0.rc.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Justin Gordon
|
|
@@ -111,14 +111,14 @@ dependencies:
|
|
|
111
111
|
requirements:
|
|
112
112
|
- - '='
|
|
113
113
|
- !ruby/object:Gem::Version
|
|
114
|
-
version: 17.0.0.rc.
|
|
114
|
+
version: 17.0.0.rc.9
|
|
115
115
|
type: :runtime
|
|
116
116
|
prerelease: false
|
|
117
117
|
version_requirements: !ruby/object:Gem::Requirement
|
|
118
118
|
requirements:
|
|
119
119
|
- - '='
|
|
120
120
|
- !ruby/object:Gem::Version
|
|
121
|
-
version: 17.0.0.rc.
|
|
121
|
+
version: 17.0.0.rc.9
|
|
122
122
|
- !ruby/object:Gem::Dependency
|
|
123
123
|
name: bundler
|
|
124
124
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -194,6 +194,7 @@ files:
|
|
|
194
194
|
- AGENTS.md
|
|
195
195
|
- CHANGELOG.md
|
|
196
196
|
- CLAUDE.md
|
|
197
|
+
- CONTEXT.md
|
|
197
198
|
- CONTRIBUTING.md
|
|
198
199
|
- Gemfile
|
|
199
200
|
- Gemfile.development_dependencies
|
|
@@ -208,6 +209,7 @@ files:
|
|
|
208
209
|
- app/helpers/react_on_rails_pro_helper.rb
|
|
209
210
|
- app/views/react_on_rails_pro/rsc_payload.text.erb
|
|
210
211
|
- babel.config.js
|
|
212
|
+
- docs/adr/0001-boot-seed-rolling-deploy-bundles.md
|
|
211
213
|
- lib/react_on_rails_pro.rb
|
|
212
214
|
- lib/react_on_rails_pro/assets_precompile.rb
|
|
213
215
|
- lib/react_on_rails_pro/async_props_emitter.rb
|