ruact 0.0.6 → 0.0.7
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/CHANGELOG.md +8 -1
- data/lib/ruact/controller.rb +9 -3
- data/lib/ruact/erb_preprocessor.rb +7 -1
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +14 -3
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +11 -4
- data/lib/ruact.rb +1 -0
- data/spec/ruact/controller_spec.rb +7 -2
- data/spec/ruact/manifest_resolver_spec.rb +159 -0
- data/spec/spec_helper.rb +15 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3ae4065926e9ba0e05bc16a221311f82af38ec3968c38066d806e8c1af04b89
|
|
4
|
+
data.tar.gz: 67d36ca87ddd0bab244352b658784238d3a97abd6b955279d7f162d221c81313
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87af4e8c9df874d9d7f5d93c87144689c73d83b8cb5097696891042bdae284de2f40c7e783317aa429e2a9c97fe318668873754c41d739a2f76c333bb07dec87
|
|
7
|
+
data.tar.gz: fd3e4f768563a86f29b074fa8c894c0faf4718789dab950325f8c8aa7cbb51e38c34565ded5f99a5e536a01b7641929d23b74ec5cd9e2cb3540e08fa97d98fdc
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.7] - 2026-06-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Boot-race that 500'd the first request in development — the manifest is now resolved from the Vite dev server over HTTP.** On a fresh app the Railtie's `config.to_prepare` read `public/react-client-manifest.json` **once at boot**, but Rails frequently booted (and read the still-missing file) **before** the Vite dev server wrote it — leaving `Ruact.manifest` `nil`. `public/` is not watched, so `to_prepare` never re-fired, and the first request to a view containing a component hit `nil.reference_for` → a cryptic 500. The bundled Vite plugin now **serves the live in-memory manifest at `GET <vite_dev_server>/__ruact/manifest`** (always fresh, reflects HMR rebuilds, internal `_sourceFile` field stripped), and in **development** the gem resolves the manifest through a new `Ruact::ManifestResolver`: it fetches that endpoint over `Net::HTTP` (~1s timeout, once per render/preprocess — not per component), **falls back** to `public/react-client-manifest.json` on disk when the dev server is down, and otherwise raises a **clear, actionable** error (`Vite dev server inacessível … rode bin/dev`) instead of a `NoMethodError`. **Production is untouched** — it still uses the boot-loaded `Ruact.manifest` (raising at boot if the build is missing); the HTTP fetch is dev-only. The Vite plugin continues to **write** `public/react-client-manifest.json` (prod build + the dev fallback). Both render and the FR100 contract validator resolve through the same path (the validator fails open to no-validation when the manifest is unreachable). The three hardcoded `http://localhost:5173` references in `ruact_js_assets` (the react-refresh preamble, `@vite/client`, and the bootstrap `<script src>`) now honor `Ruact.config.vite_dev_server` for consistency.
|
|
15
|
+
|
|
10
16
|
## [0.0.6] - 2026-06-30
|
|
11
17
|
|
|
12
18
|
> **Server functions are route-driven.** A server **mutation** is a normal non-GET
|
|
@@ -162,5 +168,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
162
168
|
- **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
|
|
163
169
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
164
170
|
|
|
165
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
171
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.7...HEAD
|
|
172
|
+
[0.0.7]: https://github.com/luizcg/ruact/releases/tag/v0.0.7
|
|
166
173
|
[0.0.6]: https://github.com/luizcg/ruact/releases/tag/v0.0.6
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -36,10 +36,16 @@ module Ruact
|
|
|
36
36
|
# only ever invoked internally by `ruact_html_shell`).
|
|
37
37
|
private :ruact_js_assets, :__ruact_component__
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
39
|
+
# Resolves the manifest for this render. In PRODUCTION this is the boot-time
|
|
40
|
+
# cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
|
|
41
|
+
# I/O. In DEVELOPMENT it fetches the live manifest from the Vite dev server
|
|
42
|
+
# (falling back to the on-disk file, then a clear error), killing the
|
|
43
|
+
# boot-race where Rails read the missing file before Vite wrote it and every
|
|
44
|
+
# first request 500'd on +nil.reference_for+. Called once per render; the
|
|
45
|
+
# returned manifest is held by RenderPipeline for the whole render (not
|
|
46
|
+
# re-fetched per component). See {Ruact::ManifestResolver}.
|
|
41
47
|
def ruact_manifest
|
|
42
|
-
|
|
48
|
+
ManifestResolver.resolve
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
# Only activate RSC rendering when the matching .html.erb template exists AND
|
|
@@ -74,7 +74,13 @@ module Ruact
|
|
|
74
74
|
line = result[0...match_start].count("\n") + 1
|
|
75
75
|
|
|
76
76
|
begin
|
|
77
|
-
|
|
77
|
+
# lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
|
|
78
|
+
# manifest (same source the render path uses, so the boot-race doesn't
|
|
79
|
+
# silence FR100 contract checks in dev) and FAILS OPEN to nil when the
|
|
80
|
+
# manifest is unresolvable (contract validation is opt-in/fail-open;
|
|
81
|
+
# the render path surfaces the clear error). In prod this is the
|
|
82
|
+
# boot-loaded Ruact.manifest, unchanged.
|
|
83
|
+
registry = ManifestResolver.resolve_soft if registry == :default
|
|
78
84
|
pairs = parse_prop_pairs(attrs_string)
|
|
79
85
|
validate_contract(registry, component_name, pairs.map(&:first),
|
|
80
86
|
at: { file: identifier, line: line, snippet: match.strip })
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
# Resolves the {Ruact::ClientManifest} to use for the current render /
|
|
7
|
+
# ERB-preprocess.
|
|
8
|
+
#
|
|
9
|
+
# PRODUCTION (unchanged): returns the boot-loaded +Ruact.manifest+ (set once by
|
|
10
|
+
# the Railtie's +config.to_prepare+; the Railtie raises at boot when the file is
|
|
11
|
+
# absent). No per-request I/O.
|
|
12
|
+
#
|
|
13
|
+
# DEVELOPMENT (the fix): the boot-time +to_prepare+ read of
|
|
14
|
+
# +public/react-client-manifest.json+ RACES the Vite dev server's first write —
|
|
15
|
+
# on a fresh app Rails can boot (and read the missing file) BEFORE Vite writes
|
|
16
|
+
# it, leaving +Ruact.manifest+ nil even though Vite is running and serving a
|
|
17
|
+
# current manifest. +public/+ is not watched, so +to_prepare+ never re-fires and
|
|
18
|
+
# the first request to a view with a component hits +nil.reference_for+ → 500.
|
|
19
|
+
#
|
|
20
|
+
# To kill that race, dev resolution fetches the LIVE manifest the Vite plugin
|
|
21
|
+
# serves in memory at +GET {vite_dev_server}/__ruact/manifest+ (always fresh,
|
|
22
|
+
# reflects HMR rebuilds), with graceful fallbacks:
|
|
23
|
+
#
|
|
24
|
+
# 1. HTTP fetch from the Vite dev server (short timeout).
|
|
25
|
+
# 2. Fallback: read +public/react-client-manifest.json+ from disk (the Vite
|
|
26
|
+
# plugin still writes it — prod needs it for the build, and it is the
|
|
27
|
+
# fallback when the dev server is down).
|
|
28
|
+
# 3. Neither available → a clear, actionable error (never a cryptic
|
|
29
|
+
# +NoMethodError+ on nil).
|
|
30
|
+
#
|
|
31
|
+
# Memoization grain: the resolver fetches ONCE PER CALL. The two call sites each
|
|
32
|
+
# invoke it once per their scope and reuse the result for every component —
|
|
33
|
+
# +RenderPipeline+ holds the returned manifest in +@manifest+ for the whole
|
|
34
|
+
# render, and +ErbPreprocessor#transform+ resolves it lazily into a local that
|
|
35
|
+
# is reused across every tag in the template. So a render does NOT re-fetch per
|
|
36
|
+
# component (the bug was many +reference_for+ calls all needing a manifest); at
|
|
37
|
+
# most one fetch for the render and one for a (re)compiled template.
|
|
38
|
+
module ManifestResolver
|
|
39
|
+
DEV_MANIFEST_PATH = "/__ruact/manifest"
|
|
40
|
+
# Dev is localhost; fail fast to the file/clear-error fallback. A refused
|
|
41
|
+
# connection (Vite simply down) raises immediately, so this timeout only
|
|
42
|
+
# bounds the pathological "listening but not answering" case.
|
|
43
|
+
HTTP_OPEN_TIMEOUT = 1
|
|
44
|
+
HTTP_READ_TIMEOUT = 1
|
|
45
|
+
|
|
46
|
+
# Resolve the manifest for a render. In dev, raises a clear
|
|
47
|
+
# {Ruact::ManifestError} when neither the dev server nor the on-disk file is
|
|
48
|
+
# available. In any non-development environment, returns +Ruact.manifest+
|
|
49
|
+
# verbatim (production behaviour is untouched).
|
|
50
|
+
#
|
|
51
|
+
# @return [Ruact::ClientManifest, nil]
|
|
52
|
+
def self.resolve
|
|
53
|
+
return Ruact.manifest unless development?
|
|
54
|
+
|
|
55
|
+
dev_manifest(soft: false)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Like {.resolve} but FAIL-OPEN in dev: returns +nil+ (rather than raising)
|
|
59
|
+
# when nothing is resolvable. Used by the ERB preprocessor's opt-in FR100
|
|
60
|
+
# contract validation, which is fail-open by design — a missing manifest must
|
|
61
|
+
# not crash template compilation; the render path then surfaces the clear
|
|
62
|
+
# error from {.resolve}.
|
|
63
|
+
#
|
|
64
|
+
# @return [Ruact::ClientManifest, nil]
|
|
65
|
+
def self.resolve_soft
|
|
66
|
+
return Ruact.manifest unless development?
|
|
67
|
+
|
|
68
|
+
dev_manifest(soft: true)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Ruact::ClientManifest, nil]
|
|
72
|
+
def self.dev_manifest(soft:)
|
|
73
|
+
manifest = fetch_over_http
|
|
74
|
+
return manifest if manifest
|
|
75
|
+
|
|
76
|
+
manifest = load_from_file
|
|
77
|
+
return manifest if manifest
|
|
78
|
+
|
|
79
|
+
return nil if soft
|
|
80
|
+
|
|
81
|
+
raise ManifestError, <<~MSG.strip
|
|
82
|
+
[ruact] Vite dev server inacessível em #{base_url} e nenhum \
|
|
83
|
+
react-client-manifest.json encontrado em #{file_path} — rode `bin/dev`.
|
|
84
|
+
MSG
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Fetch + parse the live manifest from the Vite dev server, or +nil+ when the
|
|
88
|
+
# server is unreachable / returns a non-200 / unparseable body. Never raises:
|
|
89
|
+
# any failure degrades to the file fallback.
|
|
90
|
+
#
|
|
91
|
+
# @return [Ruact::ClientManifest, nil]
|
|
92
|
+
def self.fetch_over_http
|
|
93
|
+
require "net/http"
|
|
94
|
+
require "uri"
|
|
95
|
+
|
|
96
|
+
# `chomp("/")` so a base configured WITH a trailing slash
|
|
97
|
+
# ("http://localhost:5173/") does not yield a double-slashed
|
|
98
|
+
# "//__ruact/manifest" that misses the Vite middleware mount.
|
|
99
|
+
uri = URI.parse("#{base_url.chomp('/')}#{DEV_MANIFEST_PATH}")
|
|
100
|
+
response = Net::HTTP.start(
|
|
101
|
+
uri.host, uri.port,
|
|
102
|
+
use_ssl: uri.scheme == "https",
|
|
103
|
+
open_timeout: HTTP_OPEN_TIMEOUT, read_timeout: HTTP_READ_TIMEOUT
|
|
104
|
+
) { |http| http.get(uri.request_uri) }
|
|
105
|
+
|
|
106
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
107
|
+
|
|
108
|
+
ClientManifest.from_hash(JSON.parse(response.body))
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Fallback: load the on-disk manifest the Vite plugin also writes, or +nil+
|
|
114
|
+
# when it is absent (the boot-race window) / unreadable.
|
|
115
|
+
#
|
|
116
|
+
# @return [Ruact::ClientManifest, nil]
|
|
117
|
+
def self.load_from_file
|
|
118
|
+
path = file_path
|
|
119
|
+
return nil unless path && File.exist?(path)
|
|
120
|
+
|
|
121
|
+
ClientManifest.load(path)
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [String] the configured Vite dev-server base URL.
|
|
127
|
+
def self.base_url
|
|
128
|
+
Ruact.config.vite_dev_server
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [String, nil] the on-disk manifest path (config override or default).
|
|
132
|
+
def self.file_path
|
|
133
|
+
configured = Ruact.config.manifest_path
|
|
134
|
+
return configured.to_s if configured
|
|
135
|
+
|
|
136
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
137
|
+
|
|
138
|
+
Rails.root.join("public", "react-client-manifest.json").to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [Boolean] true only in a real Rails development environment. Any
|
|
142
|
+
# other env (production, test, or no Rails at all) takes the untouched
|
|
143
|
+
# +Ruact.manifest+ path.
|
|
144
|
+
def self.development?
|
|
145
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?) &&
|
|
146
|
+
Rails.env.development?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/ruact/railtie.rb
CHANGED
|
@@ -100,9 +100,20 @@ module Ruact
|
|
|
100
100
|
# Extracted as a class method for direct testability without a full Rails app.
|
|
101
101
|
def self.check_vite!
|
|
102
102
|
require "socket"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
require "uri"
|
|
104
|
+
uri = URI.parse(Ruact.config.vite_dev_server)
|
|
105
|
+
host = uri.host || "localhost"
|
|
106
|
+
port = uri.port || 5173
|
|
107
|
+
# `connect_timeout` so a blackholed/remote configured host can't stall dev
|
|
108
|
+
# boot from `after_initialize` until the OS TCP timeout (matches
|
|
109
|
+
# ViewHelper#vite_dev_running?).
|
|
110
|
+
TCPSocket.new(host, port, connect_timeout: 1).close
|
|
111
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
|
|
112
|
+
# Broad rescue (incl. SocketError) because the host is now configurable: a
|
|
113
|
+
# misconfigured/unresolvable `vite_dev_server` must downgrade to this dev
|
|
114
|
+
# warning, never crash boot from `after_initialize`. `host`/`port` are
|
|
115
|
+
# assigned before the connect attempt, so they are always in scope here.
|
|
116
|
+
Rails.logger.warn "[ruact] Vite dev server not detected at #{host}:#{port} " \
|
|
106
117
|
"— run npm run dev for HMR"
|
|
107
118
|
end
|
|
108
119
|
|
|
@@ -166,7 +166,14 @@ module Ruact
|
|
|
166
166
|
def render_erb_enum(erb_source, binding_context, streaming:)
|
|
167
167
|
Enumerator.new do |y|
|
|
168
168
|
render_context = RenderContext.new
|
|
169
|
-
|
|
169
|
+
# `registry: nil` → fail-open, no FR100 contract validation for this
|
|
170
|
+
# low-level programmatic render path (contract checking lives on the
|
|
171
|
+
# ActionView preprocessor hook used by real controllers). This keeps the
|
|
172
|
+
# path off `Ruact::ManifestResolver` entirely — historically it read the
|
|
173
|
+
# global `Ruact.manifest` (typically nil here, so already no validation),
|
|
174
|
+
# so passing nil is byte-for-byte equivalent while avoiding any dev HTTP
|
|
175
|
+
# fetch / test-double dispatch inside callers that measure this path.
|
|
176
|
+
transformed = ErbPreprocessor.transform(erb_source, registry: nil)
|
|
170
177
|
receiver = binding_context.eval("self")
|
|
171
178
|
prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
|
|
172
179
|
inject_helper!(binding_context, render_context)
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -83,9 +83,10 @@ module Ruact
|
|
|
83
83
|
# @vitejs/plugin-react normally injects this preamble by processing index.html.
|
|
84
84
|
# Since our HTML is generated by Rails (not Vite), we inject it manually.
|
|
85
85
|
# Without it, every JSX file throws "can't detect preamble" at runtime.
|
|
86
|
+
dev_server = Ruact.config.vite_dev_server.chomp("/")
|
|
86
87
|
react_preamble = <<~JS
|
|
87
88
|
<script type="module">
|
|
88
|
-
import RefreshRuntime from '
|
|
89
|
+
import RefreshRuntime from '#{dev_server}/@react-refresh';
|
|
89
90
|
RefreshRuntime.injectIntoGlobalHook(window);
|
|
90
91
|
window.$RefreshReg$ = () => {};
|
|
91
92
|
window.$RefreshSig$ = () => (type) => type;
|
|
@@ -94,7 +95,7 @@ module Ruact
|
|
|
94
95
|
JS
|
|
95
96
|
|
|
96
97
|
react_preamble + <<~HTML
|
|
97
|
-
<script type="module" src="
|
|
98
|
+
<script type="module" src="#{dev_server}/@vite/client"></script>
|
|
98
99
|
<script type="module" src="#{ruact_bootstrap_dev_url}"></script>
|
|
99
100
|
HTML
|
|
100
101
|
else
|
|
@@ -112,12 +113,18 @@ module Ruact
|
|
|
112
113
|
# `/@id/__x00__virtual:ruact/bootstrap`. A plain `/virtual:...` URL falls
|
|
113
114
|
# through to the dev server's HTML fallback, so this encoding is required.
|
|
114
115
|
def ruact_bootstrap_dev_url
|
|
115
|
-
"
|
|
116
|
+
"#{Ruact.config.vite_dev_server.chomp('/')}/@id/__x00__#{Ruact.bootstrap_virtual_id}"
|
|
116
117
|
end
|
|
117
118
|
|
|
119
|
+
# Probe the CONFIGURED dev server (host:port parsed from
|
|
120
|
+
# `Ruact.config.vite_dev_server`), not a hardcoded localhost:5173 — so a
|
|
121
|
+
# custom `vite_dev_server` and the emitted dev `<script>` URLs always agree
|
|
122
|
+
# on whether the server is up.
|
|
118
123
|
def vite_dev_running?
|
|
119
124
|
require "socket"
|
|
120
|
-
|
|
125
|
+
require "uri"
|
|
126
|
+
uri = URI.parse(Ruact.config.vite_dev_server)
|
|
127
|
+
Socket.tcp(uri.host || "localhost", uri.port || 5173, connect_timeout: 1).close
|
|
121
128
|
true
|
|
122
129
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
|
|
123
130
|
false
|
data/lib/ruact.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "ruact/erb_preprocessor"
|
|
|
12
12
|
require_relative "ruact/render_context"
|
|
13
13
|
require_relative "ruact/html_converter"
|
|
14
14
|
require_relative "ruact/client_manifest"
|
|
15
|
+
require_relative "ruact/manifest_resolver"
|
|
15
16
|
require_relative "ruact/render_pipeline"
|
|
16
17
|
require_relative "ruact/view_helper"
|
|
17
18
|
require_relative "ruact/erb_preprocessor_hook"
|
|
@@ -39,10 +39,15 @@ module Ruact
|
|
|
39
39
|
let(:controller) { test_class.new(fake_request) }
|
|
40
40
|
|
|
41
41
|
describe "#ruact_manifest" do
|
|
42
|
-
|
|
42
|
+
# Opt OUT of spec_helper's suite-wide resolver stub here so this proves the
|
|
43
|
+
# actual delegation: `#ruact_manifest` resolves through ManifestResolver
|
|
44
|
+
# (prod returns the boot-loaded Ruact.manifest; dev fetches over HTTP).
|
|
45
|
+
it "delegates manifest resolution to ManifestResolver.resolve" do
|
|
43
46
|
test_manifest = ClientManifest.from_hash({})
|
|
44
|
-
allow(
|
|
47
|
+
allow(ManifestResolver).to receive(:resolve).and_return(test_manifest)
|
|
48
|
+
|
|
45
49
|
expect(controller.send(:ruact_manifest)).to be test_manifest
|
|
50
|
+
expect(ManifestResolver).to have_received(:resolve)
|
|
46
51
|
end
|
|
47
52
|
end
|
|
48
53
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Ruact
|
|
8
|
+
RSpec.describe ManifestResolver do
|
|
9
|
+
# spec_helper pins resolve/resolve_soft to the boot manifest suite-wide for
|
|
10
|
+
# hermeticity; this spec is the one place that exercises the REAL paths.
|
|
11
|
+
before do
|
|
12
|
+
allow(described_class).to receive(:resolve).and_call_original
|
|
13
|
+
allow(described_class).to receive(:resolve_soft).and_call_original
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:manifest_hash) do
|
|
17
|
+
{
|
|
18
|
+
"LikeButton" => {
|
|
19
|
+
"id" => "/LikeButton.jsx",
|
|
20
|
+
"name" => "LikeButton",
|
|
21
|
+
"chunks" => ["/LikeButton.jsx"]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
let(:manifest_json) { JSON.generate(manifest_hash) }
|
|
26
|
+
|
|
27
|
+
# A real Net::HTTPSuccess (HTTPOK < HTTPSuccess) whose body we override, so
|
|
28
|
+
# the resolver's `response.is_a?(Net::HTTPSuccess)` branch is exercised for
|
|
29
|
+
# real without touching the network.
|
|
30
|
+
def http_ok(body)
|
|
31
|
+
response = Net::HTTPOK.new("1.1", "200", "OK")
|
|
32
|
+
response.define_singleton_method(:body) { body }
|
|
33
|
+
response
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def in_dev
|
|
37
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development"))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe ".resolve in development (the boot-race)" do
|
|
41
|
+
before do
|
|
42
|
+
in_dev
|
|
43
|
+
# Simulate the race: boot read the missing file, so the cached manifest is nil.
|
|
44
|
+
allow(Ruact).to receive(:manifest).and_return(nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "resolves from the Vite dev server over HTTP (no 500, ref resolvable)" do
|
|
48
|
+
allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
|
|
49
|
+
|
|
50
|
+
manifest = described_class.resolve
|
|
51
|
+
|
|
52
|
+
expect(manifest).to be_a(ClientManifest)
|
|
53
|
+
ref = manifest.reference_for("LikeButton")
|
|
54
|
+
expect(ref.module_id).to eq("/LikeButton.jsx")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "fetches the manifest from the configured vite_dev_server URL" do
|
|
58
|
+
allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
|
|
59
|
+
|
|
60
|
+
described_class.resolve
|
|
61
|
+
|
|
62
|
+
expect(Net::HTTP).to have_received(:start).with(
|
|
63
|
+
"localhost", 5173, hash_including(:open_timeout, :read_timeout)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "does not double-slash the path when vite_dev_server has a trailing slash" do
|
|
68
|
+
allow(described_class).to receive(:base_url).and_return("http://localhost:5173/")
|
|
69
|
+
http = instance_double(Net::HTTP)
|
|
70
|
+
allow(http).to receive(:get).and_return(http_ok(manifest_json))
|
|
71
|
+
allow(Net::HTTP).to receive(:start).and_yield(http).and_return(http_ok(manifest_json))
|
|
72
|
+
|
|
73
|
+
described_class.resolve
|
|
74
|
+
|
|
75
|
+
expect(http).to have_received(:get).with("/__ruact/manifest")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "speaks TLS when vite_dev_server is https" do
|
|
79
|
+
allow(described_class).to receive(:base_url).and_return("https://localhost:5173")
|
|
80
|
+
allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
|
|
81
|
+
|
|
82
|
+
described_class.resolve
|
|
83
|
+
|
|
84
|
+
expect(Net::HTTP).to have_received(:start).with(
|
|
85
|
+
"localhost", 5173, hash_including(use_ssl: true)
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "falls back to the on-disk file when the dev server is unreachable" do
|
|
90
|
+
allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
|
|
91
|
+
|
|
92
|
+
Dir.mktmpdir do |dir|
|
|
93
|
+
path = File.join(dir, "react-client-manifest.json")
|
|
94
|
+
File.write(path, manifest_json)
|
|
95
|
+
allow(described_class).to receive(:file_path).and_return(path)
|
|
96
|
+
|
|
97
|
+
manifest = described_class.resolve
|
|
98
|
+
expect(manifest).to be_a(ClientManifest)
|
|
99
|
+
expect(manifest.reference_for("LikeButton").module_id).to eq("/LikeButton.jsx")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "raises a clear, actionable error (not NoMethodError) when neither HTTP nor file is available" do
|
|
104
|
+
allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
|
|
105
|
+
allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
|
|
106
|
+
|
|
107
|
+
expect { described_class.resolve }.to raise_error(ManifestError, %r{Vite dev server.*bin/dev}m)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "ignores a non-2xx dev-server response and falls back" do
|
|
111
|
+
allow(Net::HTTP).to receive(:start).and_return(Net::HTTPNotFound.new("1.1", "404", "Not Found"))
|
|
112
|
+
allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
|
|
113
|
+
|
|
114
|
+
expect { described_class.resolve }.to raise_error(ManifestError)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe ".resolve_soft in development (fail-open for FR100)" do
|
|
119
|
+
before do
|
|
120
|
+
in_dev
|
|
121
|
+
allow(Ruact).to receive(:manifest).and_return(nil)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "returns nil (no raise) when nothing is resolvable" do
|
|
125
|
+
allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
|
|
126
|
+
allow(described_class).to receive(:file_path).and_return("/no/such/manifest.json")
|
|
127
|
+
|
|
128
|
+
expect(described_class.resolve_soft).to be_nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "returns the dev manifest when the server is up" do
|
|
132
|
+
allow(Net::HTTP).to receive(:start).and_return(http_ok(manifest_json))
|
|
133
|
+
expect(described_class.resolve_soft).to be_a(ClientManifest)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe "production / non-development (untouched)" do
|
|
138
|
+
it "returns Ruact.manifest verbatim without any HTTP call" do
|
|
139
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
|
|
140
|
+
allow(Net::HTTP).to receive(:start)
|
|
141
|
+
boot_manifest = ClientManifest.from_hash(manifest_hash)
|
|
142
|
+
allow(Ruact).to receive(:manifest).and_return(boot_manifest)
|
|
143
|
+
|
|
144
|
+
expect(described_class.resolve).to be(boot_manifest)
|
|
145
|
+
expect(Net::HTTP).not_to have_received(:start)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "returns Ruact.manifest in the test environment too (no dev fetch)" do
|
|
149
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("test"))
|
|
150
|
+
allow(Net::HTTP).to receive(:start)
|
|
151
|
+
boot_manifest = ClientManifest.from_hash(manifest_hash)
|
|
152
|
+
allow(Ruact).to receive(:manifest).and_return(boot_manifest)
|
|
153
|
+
|
|
154
|
+
expect(described_class.resolve).to be(boot_manifest)
|
|
155
|
+
expect(Net::HTTP).not_to have_received(:start)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -58,5 +58,20 @@ RSpec.configure do |config|
|
|
|
58
58
|
# before every example so no leaked double survives — examples that need a
|
|
59
59
|
# specific logger set their own in their own `before` (which runs after this).
|
|
60
60
|
Rails.logger = Logger.new(IO::NULL) if defined?(Rails) && Rails.respond_to?(:logger=)
|
|
61
|
+
|
|
62
|
+
# Manifest-over-HTTP fix: the gem suite runs with `Rails.env == development`
|
|
63
|
+
# by default, where `Ruact::ManifestResolver` would otherwise fetch the live
|
|
64
|
+
# manifest from the Vite dev server (localhost:5173) on every ActionView
|
|
65
|
+
# render — making the suite hit the network and go flaky on a dev machine
|
|
66
|
+
# that happens to run a foreign Vite there. Pin the pre-fix behaviour (return
|
|
67
|
+
# the boot-loaded `Ruact.manifest`) so the whole suite is hermetic. The
|
|
68
|
+
# resolver's own spec re-stubs these `.and_call_original` to exercise the real
|
|
69
|
+
# HTTP/fallback paths with `Net::HTTP` mocked. (The programmatic
|
|
70
|
+
# `RenderPipeline#render({erb:})` path passes `registry: nil` and never
|
|
71
|
+
# touches the resolver, so the benchmark measures the real render cost.)
|
|
72
|
+
if defined?(Ruact::ManifestResolver)
|
|
73
|
+
allow(Ruact::ManifestResolver).to receive(:resolve) { Ruact.manifest }
|
|
74
|
+
allow(Ruact::ManifestResolver).to receive(:resolve_soft) { Ruact.manifest }
|
|
75
|
+
end
|
|
61
76
|
end
|
|
62
77
|
end
|
|
@@ -171,6 +171,26 @@ export default function ruact(options = {}) {
|
|
|
171
171
|
configureServer(server) {
|
|
172
172
|
const dir = path.resolve(root, componentsDir);
|
|
173
173
|
server.watcher.add(dir);
|
|
174
|
+
|
|
175
|
+
// Serve the LIVE in-memory manifest over HTTP so the Rails gem can
|
|
176
|
+
// resolve components in dev without depending on the on-disk
|
|
177
|
+
// public/react-client-manifest.json — whose first write races Rails's
|
|
178
|
+
// boot-time read (Rails can boot, and config.to_prepare read the file,
|
|
179
|
+
// BEFORE Vite writes it → Ruact.manifest nil → 500 on the first request).
|
|
180
|
+
// This endpoint always reflects the current `manifest` (rebuilt by the
|
|
181
|
+
// watcher below + buildStart), and omits the internal `_sourceFile` field
|
|
182
|
+
// so the wire shape matches the file the gem already knows how to parse.
|
|
183
|
+
server.middlewares.use("/__ruact/manifest", (req, res, next) => {
|
|
184
|
+
if (req.method !== "GET") return next();
|
|
185
|
+
const out = {};
|
|
186
|
+
for (const [name, entry] of Object.entries(manifest)) {
|
|
187
|
+
const { _sourceFile, ...rest } = entry;
|
|
188
|
+
out[name] = rest;
|
|
189
|
+
}
|
|
190
|
+
res.setHeader("Content-Type", "application/json");
|
|
191
|
+
res.end(JSON.stringify(out));
|
|
192
|
+
});
|
|
193
|
+
|
|
174
194
|
const rebuild = (file) => {
|
|
175
195
|
if (!file.startsWith(dir)) return;
|
|
176
196
|
manifest = buildManifest(dir);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Fix (manifest-over-HTTP) — the dev server exposes the live in-memory manifest
|
|
2
|
+
// at GET /__ruact/manifest so the Rails gem can resolve components without
|
|
3
|
+
// racing the on-disk react-client-manifest.json write at boot. This file
|
|
4
|
+
// covers the configureServer middleware:
|
|
5
|
+
//
|
|
6
|
+
// 1. responds with the current manifest (no internal `_sourceFile` field);
|
|
7
|
+
// 2. reflects a rebuild when a "use client" component is added/changed.
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import ruact from "./index.js";
|
|
14
|
+
|
|
15
|
+
let dir;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ruact-manifest-http-")));
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function write(rel, body) {
|
|
24
|
+
const full = path.join(dir, rel);
|
|
25
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
26
|
+
fs.writeFileSync(full, body);
|
|
27
|
+
return full;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const COMPONENT = (name) =>
|
|
31
|
+
`"use client";\nexport function ${name}() { return null; }\n`;
|
|
32
|
+
|
|
33
|
+
// A minimal connect-style server harness: captures the path-mounted middleware
|
|
34
|
+
// and the watcher callbacks the plugin registers in configureServer.
|
|
35
|
+
function makeServer() {
|
|
36
|
+
const middlewares = new Map();
|
|
37
|
+
const watcherHandlers = { change: [], add: [], unlink: [] };
|
|
38
|
+
return {
|
|
39
|
+
middlewares: { use: (route, fn) => middlewares.set(route, fn) },
|
|
40
|
+
moduleGraph: { getModuleById: () => null, invalidateModule: () => {} },
|
|
41
|
+
ws: { send: () => {} },
|
|
42
|
+
watcher: {
|
|
43
|
+
add: () => {},
|
|
44
|
+
on: (event, fn) => {
|
|
45
|
+
(watcherHandlers[event] ||= []).push(fn);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
_invokeMiddleware(route, method = "GET") {
|
|
49
|
+
const fn = middlewares.get(route);
|
|
50
|
+
let body = "";
|
|
51
|
+
let nextCalled = false;
|
|
52
|
+
const headers = {};
|
|
53
|
+
const res = {
|
|
54
|
+
setHeader: (k, v) => {
|
|
55
|
+
headers[k] = v;
|
|
56
|
+
},
|
|
57
|
+
end: (chunk) => {
|
|
58
|
+
body = chunk;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
fn({ url: route, method }, res, () => {
|
|
62
|
+
nextCalled = true;
|
|
63
|
+
});
|
|
64
|
+
return { body, headers, nextCalled };
|
|
65
|
+
},
|
|
66
|
+
_fireChange(file) {
|
|
67
|
+
for (const fn of watcherHandlers.change) fn(file);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function bootPlugin() {
|
|
73
|
+
const plugin = ruact();
|
|
74
|
+
await plugin.configResolved({ root: dir });
|
|
75
|
+
await plugin.buildStart({});
|
|
76
|
+
const server = makeServer();
|
|
77
|
+
await plugin.configureServer(server);
|
|
78
|
+
return server;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("GET /__ruact/manifest (dev server middleware)", () => {
|
|
82
|
+
it("serves the current manifest as JSON without the internal _sourceFile field", async () => {
|
|
83
|
+
write("app/javascript/components/LikeButton.jsx", COMPONENT("LikeButton"));
|
|
84
|
+
const server = await bootPlugin();
|
|
85
|
+
|
|
86
|
+
const { body, headers } = server._invokeMiddleware("/__ruact/manifest");
|
|
87
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
88
|
+
|
|
89
|
+
const manifest = JSON.parse(body);
|
|
90
|
+
expect(manifest).toHaveProperty("LikeButton");
|
|
91
|
+
expect(manifest.LikeButton).toMatchObject({
|
|
92
|
+
id: "/LikeButton.jsx",
|
|
93
|
+
name: "LikeButton",
|
|
94
|
+
chunks: ["/LikeButton.jsx"],
|
|
95
|
+
});
|
|
96
|
+
// The internal field used only during the prod build must never go on the wire.
|
|
97
|
+
expect(manifest.LikeButton).not.toHaveProperty("_sourceFile");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("reflects a rebuild when a new component is added", async () => {
|
|
101
|
+
write("app/javascript/components/LikeButton.jsx", COMPONENT("LikeButton"));
|
|
102
|
+
const server = await bootPlugin();
|
|
103
|
+
|
|
104
|
+
expect(JSON.parse(server._invokeMiddleware("/__ruact/manifest").body)).not.toHaveProperty(
|
|
105
|
+
"CommentBox"
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const added = write("app/javascript/components/CommentBox.jsx", COMPONENT("CommentBox"));
|
|
109
|
+
server._fireChange(added);
|
|
110
|
+
|
|
111
|
+
const manifest = JSON.parse(server._invokeMiddleware("/__ruact/manifest").body);
|
|
112
|
+
expect(manifest).toHaveProperty("LikeButton");
|
|
113
|
+
expect(manifest).toHaveProperty("CommentBox");
|
|
114
|
+
expect(manifest.CommentBox).not.toHaveProperty("_sourceFile");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("falls through (calls next) for non-GET methods", async () => {
|
|
118
|
+
write("app/javascript/components/LikeButton.jsx", COMPONENT("LikeButton"));
|
|
119
|
+
const server = await bootPlugin();
|
|
120
|
+
|
|
121
|
+
const { body, nextCalled } = server._invokeMiddleware("/__ruact/manifest", "POST");
|
|
122
|
+
expect(nextCalled).toBe(true);
|
|
123
|
+
expect(body).toBe("");
|
|
124
|
+
});
|
|
125
|
+
});
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruact
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Luiz Garcia
|
|
@@ -85,6 +85,7 @@ files:
|
|
|
85
85
|
- lib/ruact/flight/row_emitter.rb
|
|
86
86
|
- lib/ruact/flight/serializer.rb
|
|
87
87
|
- lib/ruact/html_converter.rb
|
|
88
|
+
- lib/ruact/manifest_resolver.rb
|
|
88
89
|
- lib/ruact/query.rb
|
|
89
90
|
- lib/ruact/railtie.rb
|
|
90
91
|
- lib/ruact/render_context.rb
|
|
@@ -159,6 +160,7 @@ files:
|
|
|
159
160
|
- spec/ruact/flight/serializer_spec.rb
|
|
160
161
|
- spec/ruact/html_converter_spec.rb
|
|
161
162
|
- spec/ruact/install_generator_spec.rb
|
|
163
|
+
- spec/ruact/manifest_resolver_spec.rb
|
|
162
164
|
- spec/ruact/query_request_spec.rb
|
|
163
165
|
- spec/ruact/query_spec.rb
|
|
164
166
|
- spec/ruact/railtie_spec.rb
|
|
@@ -204,6 +206,7 @@ files:
|
|
|
204
206
|
- vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs
|
|
205
207
|
- vendor/javascript/vite-plugin-ruact/index.js
|
|
206
208
|
- vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs
|
|
209
|
+
- vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs
|
|
207
210
|
- vendor/javascript/vite-plugin-ruact/package-lock.json
|
|
208
211
|
- vendor/javascript/vite-plugin-ruact/package.json
|
|
209
212
|
- vendor/javascript/vite-plugin-ruact/registry.test.mjs
|