Architecture

Juntos Architecture

Understanding what Juntos generates and how the pieces connect.

Table of Contents

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.html loads config/routes.js
  • Routing: Client-side, updates #hash or uses History API
  • Database: IndexedDB (Dexie), SQLite/WASM, or PGlite
  • Rendering: Direct DOM manipulation via innerHTML

Node.js / Bun / Deno

  • Entry: lib/rails.js exports Application.listen()
  • Routing: HTTP server, parses request path
  • Database: better-sqlite3, pg, mysql2
  • Rendering: Returns HTML string responses

Vercel Edge

  • Entry: api/[[...path]].js catch-all route
  • Routing: Vercel routes requests to the handler
  • Database: Neon, Turso, PlanetScale (HTTP-based)
  • Rendering: Returns Response objects

Cloudflare Workers

  • Entry: src/index.js exports fetch handler
  • Routing: Worker receives all requests
  • Database: D1 binding, Turso
  • Rendering: Returns Response objects

Capacitor (iOS/Android)

  • Entry: index.html in 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.html in system WebView
  • Routing: Client-side, same as browser
  • Database: SQLite/WASM, PGlite, or HTTP-based
  • Native APIs: Rust backend via IPC (invoke commands)
  • 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

  1. Load configuration — Read config/database.yml, determine target
  2. Copy runtime — Copy target-specific rails.js and database adapter
  3. Transpile models — Apply rails/model filter
  4. Transpile controllers — Apply rails/controller filter
  5. Transpile views — Compile ERB to Ruby, apply rails/helpers filter
  6. Transpile routes — Generate route definitions and path helpers
  7. Transpile migrations — Generate async migration functions
  8. Generate entry point — Create index.html or serverless handler
  9. 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:

  1. The generated code follows standard patterns
  2. No Ruby required at runtime—it’s pure JavaScript (the ruby2js-rails npm package provides the runtime)
  3. Add npm packages directly to dist/package.json
  4. 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

Next: Active Record