rhales 0.3.0 → 0.5.3
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/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -2
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +706 -589
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +161 -1
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
- data/lib/rhales/core/view.rb +529 -0
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/hydration/earliest_injection_detector.rb +153 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
- data/lib/rhales/hydration/hydration_injector.rb +175 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
- data/lib/rhales/hydration/mount_point_detector.rb +109 -0
- data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +55 -36
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +5 -1
- data/lib/rhales.rb +47 -19
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +142 -18
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -240
- data/lib/rhales/hydration_data_aggregator.rb +0 -220
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
- data/lib/rhales/view.rb +0 -412
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# lib/rhales/hydration/hydration_endpoint.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'digest'
|
|
6
|
+
require_relative '../utils/json_serializer'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
# Handles API endpoint responses for link-based hydration strategies
|
|
10
|
+
#
|
|
11
|
+
# Provides JSON and ES module endpoints that serve hydration data
|
|
12
|
+
# separately from HTML templates, enabling better caching, parallel
|
|
13
|
+
# loading, and reduced HTML payload sizes.
|
|
14
|
+
#
|
|
15
|
+
# ## Supported Response Formats
|
|
16
|
+
#
|
|
17
|
+
# ### JSON Response (application/json)
|
|
18
|
+
# ```json
|
|
19
|
+
# {
|
|
20
|
+
# "myData": { "user": "john", "theme": "dark" },
|
|
21
|
+
# "config": { "apiUrl": "https://api.example.com" }
|
|
22
|
+
# }
|
|
23
|
+
# ```
|
|
24
|
+
#
|
|
25
|
+
# ### ES Module Response (text/javascript)
|
|
26
|
+
# ```javascript
|
|
27
|
+
# export default {
|
|
28
|
+
# "myData": { "user": "john", "theme": "dark" },
|
|
29
|
+
# "config": { "apiUrl": "https://api.example.com" }
|
|
30
|
+
# };
|
|
31
|
+
# ```
|
|
32
|
+
#
|
|
33
|
+
# ## Usage
|
|
34
|
+
#
|
|
35
|
+
# ```ruby
|
|
36
|
+
# endpoint = HydrationEndpoint.new(config, context)
|
|
37
|
+
#
|
|
38
|
+
# # JSON response
|
|
39
|
+
# json_response = endpoint.render_json('template_name')
|
|
40
|
+
#
|
|
41
|
+
# # ES Module response
|
|
42
|
+
# module_response = endpoint.render_module('template_name')
|
|
43
|
+
# ```
|
|
44
|
+
class HydrationEndpoint
|
|
45
|
+
def initialize(config, context = nil)
|
|
46
|
+
@config = config
|
|
47
|
+
@context = context
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Render JSON response for API endpoints
|
|
51
|
+
def render_json(template_name, additional_context = {})
|
|
52
|
+
merged_data = process_template_data(template_name, additional_context)
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
content: JSONSerializer.dump(merged_data),
|
|
56
|
+
content_type: 'application/json',
|
|
57
|
+
headers: json_headers(merged_data)
|
|
58
|
+
}
|
|
59
|
+
rescue JSON::NestingError, JSON::GeneratorError, ArgumentError, Encoding::UndefinedConversionError => e
|
|
60
|
+
# Handle JSON serialization errors and encoding issues
|
|
61
|
+
error_data = {
|
|
62
|
+
error: {
|
|
63
|
+
message: "Failed to serialize data to JSON: #{e.message}",
|
|
64
|
+
template: template_name,
|
|
65
|
+
timestamp: Time.now.iso8601
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
content: JSONSerializer.dump(error_data),
|
|
71
|
+
content_type: 'application/json',
|
|
72
|
+
headers: json_headers(error_data)
|
|
73
|
+
}
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
# Handle any other unexpected errors during JSON generation
|
|
76
|
+
error_data = {
|
|
77
|
+
error: {
|
|
78
|
+
message: "Unexpected error during JSON generation: #{e.message}",
|
|
79
|
+
template: template_name,
|
|
80
|
+
timestamp: Time.now.iso8601
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
content: JSONSerializer.dump(error_data),
|
|
86
|
+
content_type: 'application/json',
|
|
87
|
+
headers: json_headers(error_data)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Render ES module response for modulepreload strategy
|
|
92
|
+
def render_module(template_name, additional_context = {})
|
|
93
|
+
merged_data = process_template_data(template_name, additional_context)
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
content: "export default #{JSONSerializer.dump(merged_data)};",
|
|
97
|
+
content_type: 'text/javascript',
|
|
98
|
+
headers: module_headers(merged_data)
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Render JSONP response with callback
|
|
103
|
+
def render_jsonp(template_name, callback_name, additional_context = {})
|
|
104
|
+
merged_data = process_template_data(template_name, additional_context)
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
content: "#{callback_name}(#{JSONSerializer.dump(merged_data)});",
|
|
108
|
+
content_type: 'application/javascript',
|
|
109
|
+
headers: jsonp_headers(merged_data),
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if template data has changed (for ETags)
|
|
114
|
+
def data_changed?(template_name, etag, additional_context = {})
|
|
115
|
+
current_etag = calculate_etag(template_name, additional_context)
|
|
116
|
+
current_etag != etag
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Get ETag for current template data
|
|
120
|
+
def calculate_etag(template_name, additional_context = {})
|
|
121
|
+
merged_data = process_template_data(template_name, additional_context)
|
|
122
|
+
# Simple ETag based on data hash
|
|
123
|
+
Digest::MD5.hexdigest(JSONSerializer.dump(merged_data))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def process_template_data(template_name, additional_context)
|
|
129
|
+
# Create a minimal context for data processing
|
|
130
|
+
template_context = create_template_context(additional_context)
|
|
131
|
+
|
|
132
|
+
# Process template to extract hydration data
|
|
133
|
+
view = View.new(@context.req, client: {})
|
|
134
|
+
aggregator = HydrationDataAggregator.new(template_context)
|
|
135
|
+
|
|
136
|
+
# Build composition to get template dependencies
|
|
137
|
+
composition = view.send(:build_view_composition, template_name)
|
|
138
|
+
composition.resolve!
|
|
139
|
+
|
|
140
|
+
# Aggregate data from all templates in the composition
|
|
141
|
+
aggregator.aggregate(composition)
|
|
142
|
+
rescue StandardError => ex
|
|
143
|
+
# Return error structure that can be serialized
|
|
144
|
+
{
|
|
145
|
+
error: {
|
|
146
|
+
message: "Failed to process template data: #{ex.message}",
|
|
147
|
+
template: template_name,
|
|
148
|
+
timestamp: Time.now.iso8601,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def create_template_context(additional_context)
|
|
154
|
+
if @context
|
|
155
|
+
# Merge additional context into existing context by reconstructing with merged props
|
|
156
|
+
merged_props = @context.client.merge(additional_context)
|
|
157
|
+
@context.class.for_view(@context.req, @context.locale, **merged_props)
|
|
158
|
+
else
|
|
159
|
+
# Create minimal context with just the additional data
|
|
160
|
+
Context.minimal(client: additional_context)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def json_headers(data)
|
|
165
|
+
headers = {
|
|
166
|
+
'Content-Type' => 'application/json',
|
|
167
|
+
'Cache-Control' => cache_control_header,
|
|
168
|
+
'Vary' => 'Accept, Accept-Encoding'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Add CORS headers if enabled
|
|
172
|
+
if cors_enabled?
|
|
173
|
+
headers.merge!(cors_headers)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Add ETag for caching
|
|
177
|
+
headers['ETag'] = %("#{Digest::MD5.hexdigest(JSONSerializer.dump(data))}")
|
|
178
|
+
|
|
179
|
+
headers
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def module_headers(data)
|
|
183
|
+
headers = json_headers(data)
|
|
184
|
+
headers['Content-Type'] = 'text/javascript'
|
|
185
|
+
headers
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def jsonp_headers(data)
|
|
189
|
+
headers = json_headers(data)
|
|
190
|
+
headers['Content-Type'] = 'application/javascript'
|
|
191
|
+
headers
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def cache_control_header
|
|
195
|
+
if @config.hydration.api_cache_enabled
|
|
196
|
+
"public, max-age=#{@config.hydration.api_cache_ttl || 300}"
|
|
197
|
+
else
|
|
198
|
+
"no-cache, no-store, must-revalidate"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def cors_enabled?
|
|
203
|
+
@config.hydration.cors_enabled || false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def cors_headers
|
|
207
|
+
{
|
|
208
|
+
'Access-Control-Allow-Origin' => @config.hydration.cors_origin || '*',
|
|
209
|
+
'Access-Control-Allow-Methods' => 'GET, HEAD, OPTIONS',
|
|
210
|
+
'Access-Control-Allow-Headers' => 'Accept, Accept-Encoding, Authorization',
|
|
211
|
+
'Access-Control-Max-Age' => '86400'
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# lib/rhales/hydration/hydration_injector.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'earliest_injection_detector'
|
|
6
|
+
require_relative 'link_based_injection_detector'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
# Handles intelligent hydration script injection with multiple strategies
|
|
10
|
+
# for optimal performance and resource loading.
|
|
11
|
+
#
|
|
12
|
+
# ## Supported Injection Strategies
|
|
13
|
+
#
|
|
14
|
+
# ### Traditional Strategies
|
|
15
|
+
# - **`:late`** (default) - Inject before </body> tag (safest, backwards compatible)
|
|
16
|
+
# - **`:early`** - Inject before detected mount points (#app, #root, etc.)
|
|
17
|
+
# - **`:earliest`** - Inject in HTML head section for maximum performance
|
|
18
|
+
#
|
|
19
|
+
# ### Link-Based Strategies (API endpoints)
|
|
20
|
+
# - **`:link`** - Basic link reference to API endpoint
|
|
21
|
+
# - **`:prefetch`** - Browser prefetch for future page loads
|
|
22
|
+
# - **`:preload`** - High priority preload for current page
|
|
23
|
+
# - **`:modulepreload`** - ES module preloading
|
|
24
|
+
# - **`:lazy`** - Intersection observer-based lazy loading
|
|
25
|
+
#
|
|
26
|
+
# ## Strategy Selection Logic
|
|
27
|
+
#
|
|
28
|
+
# 1. **Template Disable Check**: Respect `disable_early_for_templates` configuration
|
|
29
|
+
# 2. **Strategy Routing**: Execute strategy-specific injection logic
|
|
30
|
+
# 3. **Fallback Chain**: :earliest → :early → :late (when enabled)
|
|
31
|
+
# 4. **Safety Validation**: All injection points validated for HTML safety
|
|
32
|
+
#
|
|
33
|
+
# Link-based strategies generate API calls instead of inline data,
|
|
34
|
+
# enabling better caching, parallel loading, and reduced HTML payload.
|
|
35
|
+
#
|
|
36
|
+
class HydrationInjector
|
|
37
|
+
LINK_BASED_STRATEGIES = [:link, :prefetch, :preload, :modulepreload, :lazy].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(hydration_config, template_name = nil)
|
|
40
|
+
@hydration_config = hydration_config
|
|
41
|
+
@template_name = template_name
|
|
42
|
+
@strategy = hydration_config.injection_strategy
|
|
43
|
+
@fallback_to_late = hydration_config.fallback_to_late
|
|
44
|
+
@fallback_when_unsafe = hydration_config.fallback_when_unsafe
|
|
45
|
+
@disabled_templates = hydration_config.disable_early_for_templates
|
|
46
|
+
@earliest_detector = EarliestInjectionDetector.new
|
|
47
|
+
@link_detector = LinkBasedInjectionDetector.new(hydration_config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inject(template_html, hydration_html, mount_point_data = nil)
|
|
51
|
+
return template_html if hydration_html.nil? || hydration_html.strip.empty?
|
|
52
|
+
|
|
53
|
+
# Check if early/earliest injection is disabled for this template
|
|
54
|
+
if [:early, :earliest].include?(@strategy) && template_disabled_for_early?
|
|
55
|
+
return inject_late(template_html, hydration_html)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
case @strategy
|
|
59
|
+
when :early
|
|
60
|
+
inject_early(template_html, hydration_html, mount_point_data)
|
|
61
|
+
when :earliest
|
|
62
|
+
inject_earliest(template_html, hydration_html)
|
|
63
|
+
when :late
|
|
64
|
+
inject_late(template_html, hydration_html)
|
|
65
|
+
when *LINK_BASED_STRATEGIES
|
|
66
|
+
inject_link_based(template_html, hydration_html)
|
|
67
|
+
else
|
|
68
|
+
inject_late(template_html, hydration_html)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Special method for link-based strategies that need merged data context
|
|
73
|
+
def inject_link_based_strategy(template_html, merged_data, nonce = nil)
|
|
74
|
+
return template_html if merged_data.nil? || merged_data.empty?
|
|
75
|
+
|
|
76
|
+
# Check if early injection is disabled for this template
|
|
77
|
+
if template_disabled_for_early?
|
|
78
|
+
# For link strategies, we still generate the links but fall back to late positioning
|
|
79
|
+
link_html = generate_all_link_strategies(merged_data, nonce)
|
|
80
|
+
return inject_late(template_html, link_html)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
link_html = generate_all_link_strategies(merged_data, nonce)
|
|
84
|
+
|
|
85
|
+
case @strategy
|
|
86
|
+
when :earliest
|
|
87
|
+
inject_earliest(template_html, link_html)
|
|
88
|
+
when *LINK_BASED_STRATEGIES
|
|
89
|
+
inject_link_based(template_html, link_html)
|
|
90
|
+
else
|
|
91
|
+
inject_late(template_html, link_html)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def inject_early(template_html, hydration_html, mount_point_data)
|
|
98
|
+
# Fallback to late injection if no mount point found
|
|
99
|
+
if mount_point_data.nil?
|
|
100
|
+
return @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Check if the mount point data indicates an unsafe injection
|
|
104
|
+
# (This would be nil if SafeInjectionValidator found no safe position)
|
|
105
|
+
if mount_point_data[:position].nil?
|
|
106
|
+
return @fallback_when_unsafe ? inject_late(template_html, hydration_html) : template_html
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Insert hydration script before the mount element
|
|
110
|
+
position = mount_point_data[:position]
|
|
111
|
+
|
|
112
|
+
before = template_html[0...position]
|
|
113
|
+
after = template_html[position..]
|
|
114
|
+
|
|
115
|
+
"#{before}#{hydration_html}\n#{after}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def template_disabled_for_early?
|
|
119
|
+
@template_name && @disabled_templates.include?(@template_name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def inject_earliest(template_html, hydration_html)
|
|
123
|
+
begin
|
|
124
|
+
injection_position = @earliest_detector.detect(template_html)
|
|
125
|
+
rescue => e
|
|
126
|
+
# Fall back to late injection on detector error
|
|
127
|
+
return @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if injection_position
|
|
131
|
+
before = template_html[0...injection_position]
|
|
132
|
+
after = template_html[injection_position..]
|
|
133
|
+
"#{before}#{hydration_html}\n#{after}"
|
|
134
|
+
else
|
|
135
|
+
# Fallback to late injection if earliest fails
|
|
136
|
+
@fallback_to_late ? inject_late(template_html, hydration_html) : template_html
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def inject_link_based(template_html, hydration_html)
|
|
141
|
+
# For link-based strategies, try earliest injection first, then fallback
|
|
142
|
+
injection_position = @earliest_detector.detect(template_html)
|
|
143
|
+
|
|
144
|
+
if injection_position
|
|
145
|
+
before = template_html[0...injection_position]
|
|
146
|
+
after = template_html[injection_position..]
|
|
147
|
+
"#{before}#{hydration_html}\n#{after}"
|
|
148
|
+
else
|
|
149
|
+
# Fallback to late injection
|
|
150
|
+
@fallback_to_late ? inject_late(template_html, hydration_html) : template_html
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def generate_all_link_strategies(merged_data, nonce)
|
|
155
|
+
link_parts = []
|
|
156
|
+
|
|
157
|
+
merged_data.each do |window_attr, _data|
|
|
158
|
+
link_html = @link_detector.generate_for_strategy(@strategy, @template_name, window_attr, nonce)
|
|
159
|
+
link_parts << link_html
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
link_parts.join("\n")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def inject_late(template_html, hydration_html)
|
|
166
|
+
# Try to inject before closing </body> tag
|
|
167
|
+
if template_html.include?('</body>')
|
|
168
|
+
template_html.sub('</body>', "#{hydration_html}\n</body>")
|
|
169
|
+
else
|
|
170
|
+
# If no </body> tag, append to end
|
|
171
|
+
"#{template_html}\n#{hydration_html}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# lib/rhales/hydrator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative '../utils/json_serializer'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
# Data Hydrator for RSFC client-side data injection
|
|
10
|
+
#
|
|
11
|
+
# ## RSFC Security Model: Server-to-Client Security Boundary
|
|
12
|
+
#
|
|
13
|
+
# The Hydrator enforces a critical security boundary between server and client:
|
|
14
|
+
#
|
|
15
|
+
# ### Server Side (Template Rendering)
|
|
16
|
+
# - Templates have FULL server context access (like ERB/HAML)
|
|
17
|
+
# - Can access user objects, database connections, internal APIs
|
|
18
|
+
# - Can access secrets, configuration, authentication state
|
|
19
|
+
# - Can process sensitive business logic
|
|
20
|
+
#
|
|
21
|
+
# ### Client Side (Data Hydration)
|
|
22
|
+
# - Only data declared in <schema> sections reaches the browser
|
|
23
|
+
# - Creates explicit allowlist like designing a REST API
|
|
24
|
+
# - For <schema>: Direct props serialization (no interpolation)
|
|
25
|
+
# - JSON serialization validates data structure
|
|
26
|
+
#
|
|
27
|
+
# ### Process Flow (Schema-based, preferred)
|
|
28
|
+
# 1. Backend provides fully-resolved props to render call
|
|
29
|
+
# 2. Props are directly serialized as JSON
|
|
30
|
+
# 3. Client receives only the declared props
|
|
31
|
+
#
|
|
32
|
+
# ### Example (Schema-based)
|
|
33
|
+
# ```rue
|
|
34
|
+
# <schema lang="js-zod" window="appData">
|
|
35
|
+
# const schema = z.object({
|
|
36
|
+
# user_name: z.string(),
|
|
37
|
+
# theme: z.string()
|
|
38
|
+
# });
|
|
39
|
+
# </schema>
|
|
40
|
+
# ```
|
|
41
|
+
# Backend: render('template', user_name: user.name, theme: user.theme_preference)
|
|
42
|
+
#
|
|
43
|
+
#
|
|
44
|
+
# Server template can access {{user.admin?}} and {{internal_config}},
|
|
45
|
+
# but client only gets the declared user_name and theme values.
|
|
46
|
+
#
|
|
47
|
+
# This creates an API-like boundary where data is serialized once and
|
|
48
|
+
# parsed once, enforcing the same security model as REST endpoints.
|
|
49
|
+
#
|
|
50
|
+
# Note: With the new two-pass architecture, the Hydrator's role is
|
|
51
|
+
# greatly simplified. All data merging happens server-side in the
|
|
52
|
+
# HydrationDataAggregator, so this class only handles JSON generation
|
|
53
|
+
# for individual templates (used during the aggregation phase).
|
|
54
|
+
class Hydrator
|
|
55
|
+
class HydrationError < StandardError; end
|
|
56
|
+
class JSONSerializationError < HydrationError; end
|
|
57
|
+
|
|
58
|
+
attr_reader :parser, :context, :window_attribute
|
|
59
|
+
|
|
60
|
+
def initialize(parser, context)
|
|
61
|
+
@parser = parser
|
|
62
|
+
@context = context
|
|
63
|
+
@window_attribute = parser.window_attribute || 'data'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Process <schema> section and return JSON string
|
|
67
|
+
def process_data_section
|
|
68
|
+
# Check for schema section
|
|
69
|
+
if @parser.schema_lang
|
|
70
|
+
# Schema section: Direct props serialization
|
|
71
|
+
JSONSerializer.dump(@context.client)
|
|
72
|
+
else
|
|
73
|
+
# No hydration section
|
|
74
|
+
'{}'
|
|
75
|
+
end
|
|
76
|
+
rescue JSON::ParserError => ex
|
|
77
|
+
raise JSONSerializationError, "Invalid JSON in schema section: #{ex.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get processed data as Ruby hash (for internal use)
|
|
81
|
+
def processed_data_hash
|
|
82
|
+
json_string = process_data_section
|
|
83
|
+
JSONSerializer.parse(json_string)
|
|
84
|
+
rescue JSON::ParserError => ex
|
|
85
|
+
raise JSONSerializationError, "Cannot parse processed data as JSON: #{ex.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
class << self
|
|
91
|
+
# Generate only JSON data (for testing or API endpoints)
|
|
92
|
+
def generate_json(parser, context)
|
|
93
|
+
new(parser, context).process_data_section
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Generate data hash (for internal processing)
|
|
97
|
+
def generate_data_hash(parser, context)
|
|
98
|
+
new(parser, context).processed_data_hash
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# lib/rhales/hydration/link_based_injection_detector.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'strscan'
|
|
6
|
+
require_relative 'safe_injection_validator'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
# Generates link-based hydration strategies that use browser resource hints
|
|
10
|
+
# and API endpoints instead of inline scripts
|
|
11
|
+
#
|
|
12
|
+
# ## Supported Strategies
|
|
13
|
+
#
|
|
14
|
+
# - **`:link`** - Basic link reference: `<link href="/api/hydration/template">`
|
|
15
|
+
# - **`:prefetch`** - Background prefetch: `<link rel="prefetch" href="..." as="fetch">`
|
|
16
|
+
# - **`:preload`** - High priority preload: `<link rel="preload" href="..." as="fetch">`
|
|
17
|
+
# - **`:modulepreload`** - ES module preload: `<link rel="modulepreload" href="..."`
|
|
18
|
+
# - **`:lazy`** - Lazy loading with intersection observer
|
|
19
|
+
#
|
|
20
|
+
# All strategies generate both link tags and accompanying JavaScript
|
|
21
|
+
# for data fetching and assignment to window objects.
|
|
22
|
+
class LinkBasedInjectionDetector
|
|
23
|
+
def initialize(hydration_config)
|
|
24
|
+
@hydration_config = hydration_config
|
|
25
|
+
@api_endpoint_path = hydration_config.api_endpoint_path || '/api/hydration'
|
|
26
|
+
@crossorigin_enabled = hydration_config.link_crossorigin.nil? ? true : hydration_config.link_crossorigin
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def generate_for_strategy(strategy, template_name, window_attr, nonce = nil)
|
|
30
|
+
case strategy
|
|
31
|
+
when :link
|
|
32
|
+
generate_basic_link(template_name, window_attr, nonce)
|
|
33
|
+
when :prefetch
|
|
34
|
+
generate_prefetch_link(template_name, window_attr, nonce)
|
|
35
|
+
when :preload
|
|
36
|
+
generate_preload_link(template_name, window_attr, nonce)
|
|
37
|
+
when :modulepreload
|
|
38
|
+
generate_modulepreload_link(template_name, window_attr, nonce)
|
|
39
|
+
when :lazy
|
|
40
|
+
generate_lazy_loading(template_name, window_attr, nonce)
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError, "Unsupported link strategy: #{strategy}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def generate_basic_link(template_name, window_attr, nonce)
|
|
49
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
50
|
+
|
|
51
|
+
link_tag = %(<link href="#{endpoint_url}" type="application/json">)
|
|
52
|
+
|
|
53
|
+
script_tag = <<~HTML.strip
|
|
54
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
|
55
|
+
// Load hydration data for #{window_attr}
|
|
56
|
+
window.__rhales__ = window.__rhales__ || {};
|
|
57
|
+
if (!window.__rhales__.loadData) {
|
|
58
|
+
window.__rhales__.loadData = function(target, url) {
|
|
59
|
+
fetch(url)
|
|
60
|
+
.then(r => r.json())
|
|
61
|
+
.then(data => window[target] = data);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
window.__rhales__.loadData('#{window_attr}', '#{endpoint_url}');
|
|
65
|
+
</script>
|
|
66
|
+
HTML
|
|
67
|
+
|
|
68
|
+
"#{link_tag}\n#{script_tag}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def generate_prefetch_link(template_name, window_attr, nonce)
|
|
72
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
73
|
+
crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
|
|
74
|
+
|
|
75
|
+
link_tag = %(<link rel="prefetch" href="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
|
|
76
|
+
|
|
77
|
+
script_tag = <<~HTML.strip
|
|
78
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
|
79
|
+
// Prefetch hydration data for #{window_attr}
|
|
80
|
+
window.__rhales__ = window.__rhales__ || {};
|
|
81
|
+
if (!window.__rhales__.loadPrefetched) {
|
|
82
|
+
window.__rhales__.loadPrefetched = function(target, url) {
|
|
83
|
+
fetch(url)
|
|
84
|
+
.then(r => r.json())
|
|
85
|
+
.then(data => window[target] = data);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
window.__rhales__.loadPrefetched('#{window_attr}', '#{endpoint_url}');
|
|
89
|
+
</script>
|
|
90
|
+
HTML
|
|
91
|
+
|
|
92
|
+
"#{link_tag}\n#{script_tag}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def generate_preload_link(template_name, window_attr, nonce)
|
|
96
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
97
|
+
crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
|
|
98
|
+
|
|
99
|
+
link_tag = %(<link rel="preload" href="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
|
|
100
|
+
|
|
101
|
+
script_tag = <<~HTML.strip
|
|
102
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
|
103
|
+
// Preload strategy - high priority fetch
|
|
104
|
+
fetch('#{endpoint_url}')
|
|
105
|
+
.then(r => r.json())
|
|
106
|
+
.then(data => {
|
|
107
|
+
window['#{window_attr}'] = data;
|
|
108
|
+
// Dispatch ready event
|
|
109
|
+
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
110
|
+
detail: { target: '#{window_attr}', data: data }
|
|
111
|
+
}));
|
|
112
|
+
})
|
|
113
|
+
.catch(err => console.error('Rhales hydration error:', err));
|
|
114
|
+
</script>
|
|
115
|
+
HTML
|
|
116
|
+
|
|
117
|
+
"#{link_tag}\n#{script_tag}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def generate_modulepreload_link(template_name, window_attr, nonce)
|
|
121
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}.js"
|
|
122
|
+
|
|
123
|
+
link_tag = %(<link rel="modulepreload" href="#{endpoint_url}">)
|
|
124
|
+
|
|
125
|
+
script_tag = <<~HTML.strip
|
|
126
|
+
<script type="module"#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
|
127
|
+
// Module preload strategy
|
|
128
|
+
import data from '#{endpoint_url}';
|
|
129
|
+
window['#{window_attr}'] = data;
|
|
130
|
+
|
|
131
|
+
// Dispatch ready event
|
|
132
|
+
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
133
|
+
detail: { target: '#{window_attr}', data: data }
|
|
134
|
+
}));
|
|
135
|
+
</script>
|
|
136
|
+
HTML
|
|
137
|
+
|
|
138
|
+
"#{link_tag}\n#{script_tag}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def generate_lazy_loading(template_name, window_attr, nonce)
|
|
142
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
143
|
+
mount_selector = @hydration_config.lazy_mount_selector || '#app'
|
|
144
|
+
|
|
145
|
+
# No link tag for lazy loading - purely script-driven
|
|
146
|
+
script_tag = <<~HTML.strip
|
|
147
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}" data-lazy-src="#{endpoint_url}">
|
|
148
|
+
// Lazy loading strategy with intersection observer
|
|
149
|
+
window.__rhales__ = window.__rhales__ || {};
|
|
150
|
+
window.__rhales__.initLazyLoading = function() {
|
|
151
|
+
const mountElement = document.querySelector('#{mount_selector}');
|
|
152
|
+
if (!mountElement) {
|
|
153
|
+
console.warn('Rhales: Mount element "#{mount_selector}" not found for lazy loading');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const observer = new IntersectionObserver((entries) => {
|
|
158
|
+
entries.forEach(entry => {
|
|
159
|
+
if (entry.isIntersecting) {
|
|
160
|
+
fetch('#{endpoint_url}')
|
|
161
|
+
.then(r => r.json())
|
|
162
|
+
.then(data => {
|
|
163
|
+
window['#{window_attr}'] = data;
|
|
164
|
+
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
165
|
+
detail: { target: '#{window_attr}', data: data }
|
|
166
|
+
}));
|
|
167
|
+
})
|
|
168
|
+
.catch(err => console.error('Rhales lazy hydration error:', err));
|
|
169
|
+
|
|
170
|
+
observer.unobserve(entry.target);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
observer.observe(mountElement);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Initialize when DOM is ready
|
|
179
|
+
if (document.readyState === 'loading') {
|
|
180
|
+
document.addEventListener('DOMContentLoaded', window.__rhales__.initLazyLoading);
|
|
181
|
+
} else {
|
|
182
|
+
window.__rhales__.initLazyLoading();
|
|
183
|
+
}
|
|
184
|
+
</script>
|
|
185
|
+
HTML
|
|
186
|
+
|
|
187
|
+
script_tag
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def nonce_attribute(nonce)
|
|
191
|
+
require 'erb'
|
|
192
|
+
nonce ? " nonce=\"#{ERB::Util.html_escape(nonce)}\"" : ''
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|