Architecture
Juntos Architecture
Understanding what Juntos generates and how the pieces connect.
Table of Contents
- Juntos Architecture
The dist/ Directory
Running juntos build creates a self-contained JavaScript application:
dist/
├── app/
│ ├── models/
│ │ ├── application_record.js # Base class wrapping ActiveRecord
│ │ ├── article.js # Transpiled model
│ │ ├── comment.js
│ │ └── index.js # Re-exports all models
│ ├── controllers/
│ │ ├── application_controller.js
│ │ ├── articles_controller.js
│ │ └── comments_controller.js
│ ├── views/
│ │ ├── articles/
│ │ │ ├── index.js # Transpiled ERB
│ │ │ ├── show.js
│ │ │ ├── _article.js # Partials
│ │ │ └── *.html.erb # Source (for sourcemaps)
│ │ └── layouts/
│ │ └── application.js # Layout wrapper
│ ├── javascript/
│ │ └── controllers/ # Stimulus controllers
│ │ ├── index.js # Auto-generated manifest
│ │ └── *_controller.js # Transpiled from .rb
│ └── helpers/
├── config/
│ ├── routes.js # Route definitions + dispatch
│ └── paths.js # Path helper functions
├── db/
│ ├── migrate/
│ │ ├── 20241231_create_articles.js
│ │ ├── 20241231_create_comments.js
│ │ └── index.js # Migration registry
│ └── seeds.js # Seed data
├── lib/
│ ├── rails.js # Framework runtime (target-specific)
│ ├── rails_base.js # Shared base classes
│ ├── active_record.mjs # Database adapter (selected at build time)
│ ├── erb_runtime.mjs # ERB helper functions
│ └── dialects/ # SQL dialect (SQLite, PostgreSQL, or MySQL)
│ └── sqlite.mjs # Example: SQLite dialect for Turso/D1
├── node_modules/
│ └── ruby2js-rails/ # Shared runtime modules
│ └── adapters/ # ActiveRecord base classes, query builder, CollectionProxy
├── index.html # Entry point (browser targets)
├── api/[[...path]].js # Entry point (Vercel)
├── src/index.js # Entry point (Cloudflare)
├── vercel.json # Platform config (Vercel)
├── wrangler.toml # Platform config (Cloudflare)
├── package.json
└── tailwind.config.js # If using Tailwind
Standalone JavaScript
The dist/ directory is a complete application. You can:
cd dist
npm install
npm start
No Ruby required. The generated code is idiomatic JavaScript—ES2022 classes, async/await, standard module patterns. You could fork this directory and continue development in pure JavaScript.
Target Differences
Browser
- Entry:
index.htmlloadsconfig/routes.js - Routing: Client-side, updates
#hashor uses History API - Database: IndexedDB (Dexie), SQLite/WASM, or PGlite
- Rendering: Direct DOM manipulation via
innerHTML
Node.js / Bun / Deno
- Entry:
lib/rails.jsexportsApplication.listen() - Routing: HTTP server, parses request path
- Database: better-sqlite3, pg, mysql2
- Rendering: Returns HTML string responses
Vercel Edge
- Entry:
api/[[...path]].jscatch-all route - Routing: Vercel routes requests to the handler
- Database: Neon, Turso, PlanetScale (HTTP-based)
- Rendering: Returns
Responseobjects
Cloudflare Workers
- Entry:
src/index.jsexportsfetchhandler - Routing: Worker receives all requests
- Database: D1 binding, Turso
- Rendering: Returns
Responseobjects
Capacitor (iOS/Android)
- Entry:
index.htmlin WebView - Routing: Client-side, same as browser
- Database: IndexedDB (Dexie), SQLite/WASM
- Native APIs: Camera, filesystem, push notifications via Capacitor plugins
- Distribution: App Store, Google Play
Electron (Desktop)
- Entry:
main.js(main process) +index.html(renderer) - Routing: Client-side in renderer
- Database: better-sqlite3 (main process access)
- Native APIs: System tray, global shortcuts, IPC between processes
- Distribution: DMG (macOS), NSIS (Windows), AppImage (Linux)
Tauri (Lightweight Desktop)
- Entry:
index.htmlin system WebView - Routing: Client-side, same as browser
- Database: SQLite/WASM, PGlite, or HTTP-based
- Native APIs: Rust backend via IPC (
invokecommands) - Distribution: DMG (macOS), NSIS (Windows), AppImage (Linux)
- Bundle size: ~3-10MB (vs Electron’s ~150MB)
WebSocket Support
Turbo Streams broadcasting uses WebSockets for real-time updates. Support varies by target:
| Target | WebSocket Implementation |
|---|---|
| Browser | BroadcastChannel (same-origin tabs) |
| Node.js | ws package |
| Bun | Native Bun.serve WebSocket |
| Deno | Native Deno.upgradeWebSocket |
| Cloudflare | Durable Objects with hibernation |
| Vercel | Not supported (platform limitation) |
| Tauri | BroadcastChannel (same as browser) |
WebSocket connections use the /cable endpoint:
// Client subscribes to a channel
const ws = new WebSocket('ws://localhost:3000/cable');
ws.send(JSON.stringify({ command: 'subscribe', channel: 'chat_room' }));
// Server broadcasts to subscribers
TurboBroadcast.broadcast('chat_room', '<turbo-stream action="append">...</turbo-stream>');
RPC Transport Layer
For server targets (Node.js, Cloudflare, etc.), Juntos provides an RPC transport layer that enables browser-side code to call server-side model operations transparently.
How It Works
When running with a server target, model operations in the browser are proxied to the server via RPC:
Browser Server
─────── ──────
Article.find(1)
│
├──▶ POST /__rpc ──▶ Article.find(1)
X-RPC-Action: Article.find │
Body: { args: [1] } │
▼
◀── { result: { id: 1, ... } } ◀── SQLite query
RPC vs Path Helpers
Both RPC and path helpers enable browser-to-server communication, but serve different purposes:
| Feature | RPC (Model Operations) | Path Helpers |
|---|---|---|
| Purpose | Database operations | Controller actions |
| Endpoint | /__rpc (single) |
RESTful routes |
| Called by | Model classes | View components |
| Example | Article.find(1) |
articles_path.get() |
Same Source, Two Modes
The key benefit is that the same Ruby source code works on both browser and server targets:
# This code works on both targets
@articles = Article.where(status: 'published').order(created_at: :desc)
- Browser target: Queries IndexedDB via Dexie directly
- Server target: Sends RPC request, server queries SQLite/PostgreSQL
CSRF Protection
All RPC requests include CSRF tokens automatically:
// Headers sent with every RPC request
'X-Authenticity-Token': csrfToken
'X-RPC-Action': 'Article.find'
The server validates tokens before processing any RPC request.
The Runtime
Application
The Application class manages initialization and request handling:
// Browser
Application.start(); // Initialize DB, render initial route
// Node.js
Application.listen(3000); // Start HTTP server
// Vercel
export default Application.handler(); // Export request handler
// Cloudflare
export default Application.worker(); // Export Worker handler
Router
Routes are registered at build time and dispatched at runtime:
// Generated from config/routes.rb
Router.resources('articles', ArticlesController);
Router.resources('comments', CommentsController, { shallow: true });
Router.root('articles#index');
Path helpers are generated as callable objects with HTTP methods:
// config/paths.js
import { createPathHelper } from 'ruby2js-rails/path_helper.mjs';
export function article_path(article) {
return createPathHelper(`/articles/${article.id || article}`);
}
export function articles_path() {
return createPathHelper('/articles');
}
Path helpers return objects with get(), post(), patch(), put(), and delete() methods that return Response objects:
// GET request - params become query string
articles_path().get({ page: 2 }) // GET /articles.json?page=2
// POST request - params become JSON body
articles_path().post({ article: { title: 'New' } }) // POST /articles.json
// PATCH request
article_path(1).patch({ article: { title: 'Updated' } }) // PATCH /articles/1.json
// DELETE request
article_path(1).delete() // DELETE /articles/1.json
Path helpers default to JSON format and include CSRF tokens automatically for mutating requests. They also maintain backward compatibility—string coercion still works for URLs in links and templates.
See Path Helpers for complete documentation.
ActiveRecord
Models extend ApplicationRecord which wraps the database adapter:
class Article extends ApplicationRecord {
static _tableName = 'articles';
static _associations = { comments: { type: 'hasMany', foreignKey: 'article_id' } };
static _validations = { title: [{ presence: true }] };
// Generated association method returns CollectionProxy
get comments() {
return new CollectionProxy(this, this.constructor._associations.comments, Comment);
}
}
The adapter is selected at build time based on the database configuration:
| Adapter | File |
|---|---|
| Dexie | active_record_dexie.mjs |
| sql.js | active_record_sqljs.mjs |
| better-sqlite3 | active_record_better_sqlite3.mjs |
| Neon | active_record_neon.mjs |
| D1 | active_record_d1.mjs |
All adapters implement the same interface:
// Finders
Model.all() // All records
Model.find(id) // Find by ID (throws if not found)
Model.findBy({ status: 'active' }) // Find first matching (returns null)
Model.where({ status: 'active' }) // Find all matching
Model.first() // First record by ID
Model.last() // Last record by ID
Model.count() // Count records
// Chainable query builder (returns Relation)
Model.where({ published: true })
.order({ created_at: 'desc' })
.limit(10)
.offset(20)
// Raw SQL conditions (SQL adapters)
Model.where('created_at > ?', timestamp)
Model.where('status = ? AND priority > ?', 'active', 5)
// Eager loading associations
Article.includes('comments').find(id) // Preloads article.comments
Article.includes('comments', 'author') // Multiple associations
// CollectionProxy (for has_many associations)
article.comments.size // Synchronous when eagerly loaded
article.comments.build({ body: '...' }) // Pre-sets article_id automatically
article.comments.where({ approved: true }) // Returns chainable Relation
// Instance methods
record.save() // Insert or update
record.update({ title: 'New' }) // Update attributes
record.destroy() // Delete record
record.reload() // Refresh from database
Note: Raw SQL conditions work on all SQL adapters. For Dexie (IndexedDB), simple conditions like >, <, >=, <=, = are translated to Dexie’s query API; complex conditions fall back to JavaScript filtering.
See Active Record for complete documentation of the query interface, validations, callbacks, and limitations.
Sourcemaps
Each transpiled file includes a sourcemap linking back to the original Ruby:
// article.js
export class Article extends ApplicationRecord { ... }
//# sourceMappingURL=article.js.map
The original .rb files are copied alongside for debugger access. In browser DevTools, you can set breakpoints on Ruby lines and step through Ruby code.
The Build Process
- Load configuration — Read
config/database.yml, determine target - Copy runtime — Copy target-specific
rails.jsand database adapter - Transpile models — Apply rails/model filter
- Transpile controllers — Apply rails/controller filter
- Transpile views — Compile ERB to Ruby, apply rails/helpers filter
- Transpile routes — Generate route definitions and path helpers
- Transpile migrations — Generate async migration functions
- Generate entry point — Create index.html or serverless handler
- Setup Tailwind — If detected, configure and build CSS
Note: Shared modules (ActiveRecord base classes, query builder, inflector) are imported from the ruby2js-rails npm package rather than copied to dist/. This keeps builds smaller and provides a single source of truth for the runtime code.
Continuing in JavaScript
After building, you can take dist/ and develop purely in JavaScript:
- The generated code follows standard patterns
- No Ruby required at runtime—it’s pure JavaScript (the
ruby2js-railsnpm package provides the runtime) - Add npm packages directly to
dist/package.json - Modify transpiled files as needed
The generated code isn’t obfuscated or minified—it’s meant to be readable and maintainable. This is an intentional escape hatch: Juntos gets you started quickly, but you’re not locked in.
Next Steps
- Testing — Write tests for your transpiled app
- Hotwire — Real-time features with Turbo and Stimulus
- Deployment — Platform-specific deployment guides