Rhales - Ruby Single File Components
[!CAUTION] Early Development Release - Rhales is in active development (v0.5). The API underwent breaking changes from v0.4. While functional and tested, it’s recommended for experimental use and contributions. Please report issues and provide feedback through GitHub.
Rhales is a type-safe contract enforcement framework for server-rendered pages with client-side data hydration. It uses .rue
files (Ruby Single File Components) that combine Zod schemas, Handlebars templates, and documentation into a single contract-first format.
About the name: It all started with a simple mustache template many years ago. Mustache’s successor, “Handlebars,” is a visual analog for a mustache. “Two Whales Kissing” is another visual analog for a mustache, and since we’re working with Ruby, we call it “Rhales” (Ruby + Whales). It’s a perfect name with absolutely no ambiguity or risk of confusion.
What’s New in v0.5
- ✅ Schema-First Design: Replaced
<data>
sections with Zod v4<schema>
sections - ✅ Type Safety: Contract enforcement between backend and frontend
- ✅ Simplified API: Removed deprecated parameters (
sess
,cust
,props:
,app_data:
) - ✅ Clear Context Layers: Renamed
app
→request
for clarity - ✅ Schema Tooling: Rake tasks for schema generation and validation
- ✅ 100% Migration: All demo templates use schemas
Breaking changes from v0.4: See Migration Guide below.
Features
- Schema-based hydration with Zod v4 for type-safe client data
- Server-side rendering with Handlebars-style template syntax
- Three-layer context for request, server, and client data separation
- Security-first design with explicit server-to-client boundaries
- Layout & partial composition for component reuse
- CSP support with automatic nonce generation
- Framework agnostic - works with Rails, Roda, Sinatra, Grape, Padrino
- Dependency injection for testability and flexibility
Installation
Add to your Gemfile:
ruby
gem 'rhales'
Then execute:
bash
bundle install
Quick Start
1. Configure Rhales
```ruby # config/initializers/rhales.rb or similar Rhales.configure do |config| config.default_locale = ‘en’ config.template_paths = [‘templates’] config.features = { dark_mode: true } config.site_host = ‘example.com’
# CSP configuration config.csp_enabled = true config.auto_nonce = true end
Optional: Configure logger
Rhales.logger = Rails.logger ```
2. Create a .rue Component
Create templates/hello.rue
:
```xml
{greeting}, {userName}!
Welcome to Rhales v0.5
```
3. Render in Your Application
```ruby # In your controller/route handler view = Rhales::View.new( request, client: { greeting: ‘Hello’, userName: ‘World’ } )
html = view.render(‘hello’) # Returns HTML with schema-validated data injected as window.appData ```
The .rue File Format
A .rue
file contains three sections:
```xml <schema lang=”js-zod” window=”data” [version=”2”] [envelope=”Envelope”] [layout=”layouts/main”]> const schema = z.object({ // Zod v4 schema defining client data contract }); </schema>
```
Schema Section Attributes
Attribute | Required | Description | Example |
---|---|---|---|
lang |
Yes | Schema language (currently only js-zod ) |
"js-zod" |
window |
Yes | Browser global name | "appData" → window.appData |
version |
No | Schema version | "2" |
envelope |
No | Response wrapper type | "SuccessEnvelope" |
layout |
No | Layout template reference | "layouts/main" |
Zod Schema Examples
```javascript // Simple types z.object({ user: z.string(), count: z.number(), active: z.boolean() })
// Complex nested structures z.object({ user: z.object({ id: z.number(), name: z.string(), email: z.string().email() }), items: z.array(z.object({ id: z.number(), title: z.string(), price: z.number().positive() })), metadata: z.record(z.string()) })
// Optional and nullable z.object({ theme: z.string().optional(), lastLogin: z.string().nullable() }) ```
Context and Data Model
Rhales uses a three-layer context system that separates concerns and enforces security boundaries:
1. Request Layer (Framework Data)
Framework-provided data available under the request
namespace:
handlebars
{{request.nonce}} <!-- CSP nonce for scripts -->
{{request.csrf_token}} <!-- CSRF token for forms -->
{{request.authenticated?}} <!-- Authentication state -->
{{request.locale}} <!-- Current locale -->
Available Request Variables:
- request.nonce
- CSP nonce for inline scripts/styles
- request.csrf_token
- CSRF token for form submissions
- request.authenticated?
- User authentication status
- request.locale
- Current locale (e.g., ‘en’, ‘es’)
- request.session
- Session object (if available)
- request.user
- User object (if available)
2. Server Layer (Template-Only Data)
Application data that stays on the server (not sent to browser):
ruby
view = Rhales::View.new(
request,
server: {
page_title: 'Dashboard',
vite_assets_html: vite_javascript_tag('application'),
admin_notes: 'Internal use only' # Never sent to client
}
)
handlebars
{{server.page_title}} <!-- Available in templates -->
{{server.vite_assets_html}} <!-- Server-side only -->
3. Client Layer (Serialized to Browser)
Data serialized to browser via schema validation:
ruby
view = Rhales::View.new(
request,
client: {
user: current_user.name,
items: Item.all.map(&:to_h)
}
)
handlebars
{{client.user}} <!-- Also serialized to window.appData.user -->
{{client.items}} <!-- Also serialized to window.appData.items -->
Context Layer Fallback
Variables can use shorthand notation (checks client
→ server
→ request
):
```handlebars {client{client.user} {server{server.page_title} {request{request.nonce}
{user} {page_title} {nonce} ```
Security Model: Server-to-Client Boundary
The .rue
format enforces a security boundary at the server-to-client handoff:
Server Templates: Full Context Access
Templates have access to ALL context layers:
```handlebars request.authenticated?}
Welcome {client{client.user}
Secret: {server{server.admin_notes}
{/if} ```
Client Data: Explicit Allowlist
Only schema-declared data reaches the browser:
```xml
```
Result on client:
javascript
window.data = {
user: "Alice",
userId: 123
// admin_notes, secret_key NOT included (never declared in schema)
}
This creates a REST API-like boundary where you explicitly declare what data crosses the security boundary.
⚠️ Critical: Schema Validates, Does NOT Filter
IMPORTANT: The schema does NOT filter which data gets serialized. The ENTIRE client:
hash is serialized to the browser. The schema only validates that the serialized data matches the expected structure.
```ruby # ⚠️ DANGER: ALL client data serialized (including password!) view = Rhales::View.new(request, client: { user: ‘Alice’, password: ‘secret123’, # ← Serialized to browser! api_key: ‘xyz’ # ← Serialized to browser! } )
Schema only validates structure, doesn’t prevent serialization
# If schema doesn’t include password/api_key, validation FAILS # But data already leaked to browser in HTML response ```
Your Responsibility: Ensure the client:
hash contains ONLY safe, public data. Never pass:
- Passwords or credentials
- API keys or secrets
- Internal URLs or configuration
- Personally identifiable information (PII) not intended for client
The schema is a contract validator, not a data filter.
Complete Example: Dashboard
Backend (Ruby)
```ruby # config/routes.rb (Rails) or route handler class DashboardController < ApplicationController def show view = Rhales::View.new( request, client: { user: current_user.name, userId: current_user.id, items: current_user.items.map { |i| { id: i.id, name: i.name, price: i.price } }, apiBaseUrl: ENV[‘API_BASE_URL’] }, server: { page_title: ‘Dashboard’, internal_notes: ‘User has premium access’, # Server-only vite_assets: vite_javascript_tag(‘application’) } )
render html: view.render('dashboard').html_safe end end ```
Frontend (.rue file)
```xml
{server{server.page_title}
request.authenticated?}Welcome, {client{client.user}!
{name}
${price}
Please log in
{/if}```
Generated HTML
```html
Dashboard
Welcome, Alice!
Widget
$19.99
Gadget
$29.99
```
Template Syntax
Rhales uses Handlebars-style syntax:
Variables
handlebars
{{variable}} <!-- HTML-escaped (safe) -->
{{{variable}}} <!-- Raw output (use carefully!) -->
{{object.property}} <!-- Dot notation -->
{{array.0}} <!-- Array index -->
Conditionals
```handlebars condition} Content when true {else} Content when false {/if}
condition} Content when false {/unless} ```
Truthy/Falsy:
- Falsy: nil
, null
, false
, ""
, 0
, "false"
- Truthy: All other values
Loops
handlebars
{{#each items}}
{{@index}} <!-- 0-based index -->
{{@first}} <!-- true if first item -->
{{@last}} <!-- true if last item -->
{{this}} <!-- current item (if primitive) -->
{{name}} <!-- item.name (if object) -->
{{/each}}
Partials
handlebars
{{> header}} <!-- Include templates/header.rue -->
{{> components/nav}} <!-- Include templates/components/nav.rue -->
Layouts
```xml
Home Page Content
```
```xml
```
Schema Tooling
Rhales provides rake tasks for schema management:
```bash # Generate JSON schemas from .rue templates rake rhales:schema:generate TEMPLATES_DIR=./templates
Validate existing JSON schemas
rake rhales:schema:validate
Show schema statistics
rake rhales:schema:stats TEMPLATES_DIR=./templates ```
Example output:
``` Schema Statistics ============================================================ Templates directory: templates
Total .rue files: 25
Files with
By language: js-zod: 25 ```
Framework Integration
Rails
```ruby # config/initializers/rhales.rb Rhales.configure do |config| config.template_paths = [‘app/templates’] config.default_locale = ‘en’ end
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base def render_rhales(template_name, client: {}, server: {}) view = Rhales::View.new(request, client: client, server: server) view.render(template_name) end end
In your controller
def dashboard html = render_rhales(‘dashboard’, client: { user: current_user.name, items: @items }, server: { page_title: ‘Dashboard’ } ) render html: html.html_safe end ```
Roda
```ruby # app.rb require ‘roda’ require ‘rhales’
class App < Roda plugin :render
Rhales.configure do |config| config.template_paths = [‘templates’] config.default_locale = ‘en’ end
route do |r| r.on ‘dashboard’ do view = Rhales::View.new( request, client: { user: current_user.name }, server: { page_title: ‘Dashboard’ } ) view.render(‘dashboard’) end end end ```
Sinatra
```ruby require ‘sinatra’ require ‘rhales’
Rhales.configure do |config| config.template_paths = [‘templates’] config.default_locale = ‘en’ end
helpers do def render_rhales(template_name, client: {}, server: {}) view = Rhales::View.new(request, client: client, server: server) view.render(template_name) end end
get ‘/dashboard’ do render_rhales(‘dashboard’, client: { user: ‘Alice’ }, server: { page_title: ‘Dashboard’ } ) end ```
Grape
```ruby require ‘grape’ require ‘rhales’
Rhales.configure do |config| config.template_paths = [‘templates’] config.default_locale = ‘en’ end
class MyAPI < Grape::API helpers do def render_rhales(template_name, client: {}, server: {}) mock_request = OpenStruct.new(env: env) view = Rhales::View.new(mock_request, client: client, server: server) view.render(template_name) end end
get ‘/dashboard’ do content_type ‘text/html’ render_rhales(‘dashboard’, client: { user: ‘Alice’ }, server: { page_title: ‘Dashboard’ } ) end end ```
Content Security Policy (CSP)
Rhales provides security by default with automatic CSP support.
Default CSP Configuration
ruby
Rhales.configure do |config|
config.csp_enabled = true # Default: true
config.auto_nonce = true # Default: true
end
Using Nonces in Templates
```handlebars
```
Custom CSP Policies
ruby
Rhales.configure do |config|
config.csp_policy = {
'default-src' => ["'self'"],
'script-src' => ["'self'", "'nonce-{{nonce}}'", 'https://cdn.example.com'],
'style-src' => ["'self'", "'nonce-{{nonce}}'"],
'img-src' => ["'self'", 'data:', 'https://images.example.com'],
'connect-src' => ["'self'", 'https://api.example.com']
}
end
Framework CSP Header Setup
Rails
```ruby class ApplicationController < ActionController::Base after_action :set_csp_header
private
def set_csp_header csp_header = request.env[‘csp_header’] response.headers[‘Content-Security-Policy’] = csp_header if csp_header end end ```
Roda
ruby
class App < Roda
def render_with_csp(template_name, **data)
result = render_rhales(template_name, **data)
csp_header = request.env['csp_header']
response.headers['Content-Security-Policy'] = csp_header if csp_header
result
end
end
Logging
Rhales provides production logging for security auditing and debugging:
ruby
# Configure logger
Rhales.logger = Rails.logger # or Logger.new($stdout)
Logged Events: - View rendering (template, layout, partials, timing, hydration size) - Security warnings (unescaped variables, schema mismatches) - Errors with context (line numbers, sections, full messages) - Performance insights (cache hits, compilation timing)
ruby
# Example log output
INFO View rendered: template=dashboard layout=main partials=[header,footer] duration_ms=15.2
WARN Hydration schema mismatch: template=user_profile missing=[email] extra=[]
ERROR Template not found: template=missing_partial parent=dashboard
DEBUG Template cache hit: template=header
Testing
Test Configuration
```ruby # test/test_helper.rb or spec/spec_helper.rb require ‘rhales’
Rhales.configure do |config| config.default_locale = ‘en’ config.app_environment = ‘test’ config.cache_templates = false config.template_paths = [‘test/fixtures/templates’] end ```
Testing Context
```ruby # Minimal context for testing context = Rhales::Context.minimal( client: { user: ‘Test’ }, server: { page_title: ‘Test Page’ } )
expect(context.get(‘user’)).to eq(‘Test’) expect(context.get(‘page_title’)).to eq(‘Test Page’) ```
Testing Templates
```ruby # Test inline template template = ‘active}Active{else}Inactive{/if}’ result = Rhales.render_template(template, active: true) expect(result).to eq(‘Active’)
Test .rue file
mock_request = OpenStruct.new(env: {}) view = Rhales::View.new(mock_request, client: { message: ‘Hello’ }) html = view.render(‘test_template’) expect(html).to include(‘Hello’) ```
Migration from v0.4 to v0.5
Breaking Changes
<data>
sections removed → Use<schema>
sections- Parameters removed:
sess
→ Access viarequest.session
cust
→ Access viarequest.user
props:
→ Useclient:
app_data:
→ Useserver:
locale
→ Set viarequest.env['rhales.locale']
- Context layer renamed:
app
→request
Migration Steps
1. Update Ruby Code
```ruby # v0.4 (REMOVED) view = Rhales::View.new(req, session, customer, ‘en’, props: { user: customer.name }, app_data: { page_title: ‘Dashboard’ } )
v0.5 (Current)
view = Rhales::View.new(req, client: { user: customer.name }, server: { page_title: ‘Dashboard’ } )
Set locale in request
req.env[‘rhales.locale’] = ‘en’ ```
2. Convert Data to Schema
```xml
{ "user": "{user{user.name}", "count": {items{items.count} }```
Key difference: In v0.5, pass resolved values in client:
hash instead of relying on template interpolation in JSON.
3. Update Context References
```handlebars {app{app.nonce} {app{app.csrf_token}
{request{request.nonce} {request{request.csrf_token} ```
4. Update Backend Data Passing
```ruby # v0.4: Template interpolation view = Rhales::View.new(req, sess, cust, ‘en’, props: { user: cust } # Object reference, interpolated in )
v0.5: Resolved values upfront
view = Rhales::View.new(req, client: { user: cust.name, # Resolved value userId: cust.id # Resolved value } ) ```
Performance Optimization
Optional: Oj for Faster JSON Processing
Rhales includes optional support for Oj, a high-performance JSON library that provides:
- 10-20x faster JSON parsing compared to stdlib
- 5-10x faster JSON generation compared to stdlib
- Lower memory usage for large data payloads
- Full compatibility with stdlib JSON API
Installation
Add to your Gemfile:
ruby
gem 'oj', '~> 3.13'
Then run:
bash
bundle install
That’s it! Rhales automatically detects Oj at load time and uses it for all JSON operations.
Note: The backend is selected once when Rhales loads. To ensure Oj is used, require it before Rhales:
ruby
# Gemfile or application initialization
require 'oj' # Load Oj first
require 'rhales' # Rhales will detect and use Oj
Most bundler setups handle this automatically, but explicit ordering ensures optimal performance.
Verification
Check which backend is active:
ruby
Rhales::JSONSerializer.backend
# => :oj (if available) or :json (stdlib)
Performance Impact
For typical Rhales applications with hydration data:
Operation | stdlib JSON | Oj | Improvement |
---|---|---|---|
Parse 100KB payload | ~50ms | ~3ms | 16x faster |
Generate 100KB payload | ~30ms | ~5ms | 6x faster |
Memory usage | Baseline | -20% | Lower |
Recommendation: Install Oj for production applications with: - Large hydration payloads (>10KB) - High-traffic endpoints (>100 req/sec) - Complex nested data structures
Oj provides the most benefit for data-heavy templates and high-concurrency scenarios.
Development
```bash # Clone repository git clone https://github.com/onetimesecret/rhales.git cd rhales
Install dependencies
bundle install
Run tests
bundle exec rspec spec/rhales/
Run with documentation format
bundle exec rspec spec/rhales/ –format documentation
Build gem
gem build rhales.gemspec
Install locally
gem install ./rhales-0.5.0.gem ```
Contributing
- Fork it (https://github.com/onetimesecret/rhales/fork)
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request
License
The gem is available as open source under the MIT License.
AI Development Assistance
Rhales was developed with assistance from AI tools:
- Claude Sonnet 4.5 - Architecture design, code generation, documentation
- Claude Desktop & Claude Code - Interactive development and debugging
- GitHub Copilot - Code completion and refactoring
- Qodo Merge Pro - Code review and quality improvements
I remain responsible for all design decisions and the final code. Being transparent about development tools as AI becomes more integrated into our workflows.