Writebook on Juntos
Writebook is Basecamp's collaborative book publishing application. It's a real Rails 8 app with models, concerns, controllers, views, Stimulus controllers, Turbo Streams, Active Storage, full-text search, and session-based authentication.
Ruby2JS transpiles Ruby to JavaScript. Juntos takes that further: it transpiles an entire Rails application — models, controllers, views, routes, migrations, tests — into a standalone JavaScript project that runs on Node.js, Deno, Cloudflare Workers, or in the browser.
The developer writes Ruby. The deployment runs JavaScript. No Ruby runtime needed in production.
If the transpiler can handle Writebook, it can handle most Rails applications.
The approach
Rather than trying to predict what features would be needed, we used Writebook to find out. The process was iterative: eject the application, try to start it, fix whatever breaks, repeat. Each fix addressed a general gap in the transpiler — not a Writebook-specific hack.
Starting from npx juntos eject, we worked through:
- Transform failures — files that couldn't be transpiled at all
- Syntax errors — valid Ruby patterns producing invalid JavaScript
- Import resolution — missing imports, wrong paths, circular dependencies
- Runtime errors — code that transpiles correctly but fails at execution
What we fixed along the way
Every fix was a general improvement to Ruby2JS or Juntos. Writebook surfaced the issues; the solutions apply to any Rails app.
Transpiler fixes
-
delegated_type— Rails' polymorphic type delegation pattern, used by Writebook for pages, sections, and pictures. The model filter now generates type-specific getters and scoped class queries. -
Inline
rescueas expression —x = foo rescue nilwas generating JavaScript withoutreturnstatements in the IIFE wrapper. A pre-existing bug that affected any expression-context rescue. -
Consecutive capitals in
underscore—QRCodewas becomingq_r_codeinstead ofqr_code. The inflector now treats consecutive uppercase letters as acronyms. -
Anonymous block parameters — Ruby 3.1's
def foo(&)was leaving trailing commas in JavaScript function signatures. -
Cross-file constant resolution — concern constants like
Leafable::TYPESreferenced from other files. The build pipeline now retries deferred files after metadata is populated, handling arbitrary dependency chains with zero overhead for apps that don't need it.
Concern handling
-
Callbacks in concerns —
before_createblocks in concern factories were emitted after the factory function, where they can't execute. Now emitted asstaticproperty initializers inside the anonymous class body. -
Multiple includes —
include Editable, Positionable, Searchablewas only processing the first include. The model filter now handles all includes, generating the full mixin chain:class Leaf extends Searchable(Positionable(Editable(ApplicationRecord))). -
Namespaced concerns —
Account::Joinableatapp/models/account/joinable.rbwas imported fromconcerns/joinable.js. The concern filter now records file paths in metadata for correct import resolution.
Routes and controllers
-
scope module:tracking — nested controller routes likescope module: "pages" do resources :edits endweren't generating the correct controller file paths. The routes filter now maintains a module stack. -
Singular resource controllers —
resource :sessionusesSessionsController(pluralized), notSessionController. Fixed the naming convention. -
Custom route imports — controllers referenced by
get "/:id/:slug", to: "leafables#show"weren't imported. The routes filter now imports controllers from custom routes and singular resources. -
directroutes — Rails' custom URL helper DSL. The routes filter now transpilesdirect :name do |args| ... endblocks into exported functions. -
_urlvs_pathhelpers —book_slug_url(@book)in views becomesnew URL(book_slug_path(book), $context.request.url).toString(). The rewrite uses route metadata to distinguish path helpers from concern methods with similar names.
Runtime additions
-
CurrentAttributes— extracted from the dev runtime into an importablejuntos/current_attributes.mjsmodule. The model filter generates direct imports instead of relying on globals. -
has_secure_password— real bcrypt implementation with lazy-loadedbcryptjs. Password hashing viabefore_savecallback,authenticatemethod for verification. -
delegate— static method onApplicationRecordthat creates forwarding getters. -
Target-aware assets —
image_tag "logo.svg"produces Vite imports for browser targets, static/images/logo.svgpaths for Node targets.
Infrastructure
-
App-specific filters — a
config/ruby2js_filter.jsfile is loaded automatically and prepended to all filter chains. This enables transpile-time metaprogramming: patterns likepositioned_within :book, association: :leavesare expanded into explicit method definitions before the model filter sees them. -
Import resolver refactor — replaced scattered regex replacements with a single-pass table-driven resolver.
-
rails_stubs.mjs—Function.prototypestubs for Rails DSL methods called on concern factory functions, ensuring module evaluation doesn't crash ondelegate,validates, etc.
Where it stands
After two days of work:
$ cd writebook/ejected && node main.js
Initializing database...
Running migrations...
Ran 5 migration(s)
Running seeds...
Starting server on http://localhost:3000
The ejected server starts, initializes a SQLite database, runs all 5 migrations, executes seeds, and responds to HTTP requests. The book index route dispatches to the controller, which queries the database and calls the view renderer.
The first page render hits a cache is not defined error — a Rails view helper that needs a JavaScript equivalent. This is the next thing to fix: view helpers that reference Rails framework methods.
What's next
The remaining work falls into clear categories:
View helpers — cache, content_for, simple_format, and other Rails view methods need JavaScript implementations or stubs.
App-specific filter — Writebook uses define_method in one concern and attachment_reflections for dynamic iteration. A Writebook-specific filter handles these by expanding them at transpile time using information available in the source.
Missing controllers — some resource declarations reference controllers that don't exist in the app (Rails would return 404). The eject pipeline should detect and skip these.
Authentication flow — has_secure_password is implemented, but the session management, cookie handling, and before_action authentication checks need wiring through the request context.
The bigger picture
Writebook proved that the transpiler handles real Rails patterns. Every issue we found was a general gap, not a fundamental limitation. The architecture works: Ruby source → AST transformation → JavaScript output. Concerns mix in, callbacks register, associations resolve, routes dispatch.
The interesting insight is about metaprogramming. Ruby does it at runtime; JavaScript can't. But every metaprogramming pattern in Writebook uses information available at transpile time — declared attachments, literal symbol arguments, known concern structure. A transpile-time filter can expand these patterns just as effectively as Ruby's runtime, and the developer can inspect the result.
The full source is at github.com/ruby2js/ruby2js. Juntos documentation is at ruby2js.com/docs/juntos.