Ruby2JS Gets Prism Support - Enabling Shared Ruby/JavaScript Logic
Ruby2JS now supports Ruby 3.3's Prism parser, opening the door to eliminating duplicate business logic between Ruby servers and JavaScript clients.
Pull request: ruby2js/ruby2js#229
Why This Matters
My recent work on offline-first scoring revealed a fundamental problem: business logic lives in two places.
Server (Ruby): Controllers compute derived values, expand scoring categories, determine display formats.
Client (JavaScript): Hydrators duplicate this logic to reconstruct the same data structures from normalized JSON.
When logic changes, both implementations must be updated in lockstep. This creates maintenance burden and risk of drift. The JSON contract between server and client is implicit—defined only by code on each side.
The Vision: Single Source of Truth
What if model annotations declared serialization contracts, and Ruby2JS transpiled shared logic to JavaScript?
# app/models/heat.rb
class Heat < ApplicationRecord
spa_serialize do
attributes :id, :number, :category, :ballroom
# Pre-computed on server, sent as value
computed :dance_string, precompute: true
# Shared logic: block converted to JS via Ruby2JS
computed :display_category do |heat|
heat.pro ? 'Professional' : "#{heat.level.initials} - #{heat.age.category}"
end
references :dance, :entry, :solo
end
end
Ruby2JS converts the block to JavaScript:
displayCategory(heat) {
return heat.pro ? 'Professional' : `${heat.level.initials} - ${heat.age.category}`;
}
Both server and client execute identical logic. No duplication. No drift.
Prism Migration: The Technical Challenge
Ruby2JS was built on the whitequark parser gem. Prism, introduced in Ruby 3.3, produces a different AST structure. Rather than rewrite ~60 AST handlers, I used Prism::Translation::Parser—a compatibility layer that translates Prism's AST into whitequark parser format.
This approach required:
- Parser selection logic: Auto-detect Prism if available, fall back to parser gem
- Environment override:
RUBY2JS_PARSER=prismorRUBY2JS_PARSER=parser - Edge case fixes for subtle differences between parsers
Edge Cases
__FILE__ handling: Prism produces :str nodes with the filename; the parser gem produces :__FILE__ nodes. The node filter now handles both.
Comment association: Filters create new AST nodes that don't exist in the original source. The converter now does location-based lookup to find comments for these synthetic nodes.
Consecutive tilde handling: The jQuery filter uses ~~ for jQuery wrapping. Prism's AST structure required explicit handling of :attr nodes in the recursion.
Class-level comments: The Vue filter generates new class definition nodes. Comments from the original class now propagate to the generated output.
Results
All 1302 tests pass with both parsers. The implementation is fully backwards compatible—existing code continues to work without modification.
Implementation Details
The core changes are in lib/ruby2js.rb:
# Auto-detect: try Prism first, fall back to parser gem
case ENV['RUBY2JS_PARSER']
when 'prism'
require 'prism'
require 'parser/current'
RUBY2JS_PARSER = :prism
when 'parser'
# ... use parser gem
else
begin
require 'prism'
require 'parser/current'
RUBY2JS_PARSER = :prism
rescue LoadError
# ... fall back to parser gem
end
end
The Filter::Processor class was refactored from inheriting Parser::AST::Processor to a standalone implementation. This provides explicit control over node traversal and comment handling—both areas where Prism compatibility required adjustments.
What This Enables
With Prism support, Ruby2JS can process blocks defined anywhere in your codebase:
# DSL-captured blocks work
computed :name do |model|
model.field.upcase
end
# Multi-line complex blocks work
expand_subjects = proc do |subjects, enabled|
return subjects unless enabled
subjects.flat_map do |subject|
# ... complex expansion logic
end
end
Ruby2JS reads the block's source via source_location, parses it with Prism, and converts to equivalent JavaScript.
The Broader Architecture
This is Stage 1 of a three-stage plan:
Stage 1 (complete): Prism migration with full backwards compatibility
Stage 2 (planned): Annotation DSL + code generator proof of concept
spa_serializeDSL for model annotations- Generator that produces Ruby serializers and JavaScript hydrators
- Validation with one simple model
Stage 3 (planned): Full model coverage
- All models annotated
- Replace hand-written
heats_dataserialization (~200 lines → ~50 lines) - Replace hand-written
heat_hydrator.js(~400 lines → ~100 lines) - Single source of truth for all serialization contracts
Try It
The Prism support is available on the prism-ast branch:
git clone https://github.com/ruby2js/ruby2js
cd ruby2js
git checkout prism-ast
bundle install
bundle exec rake test
Force a specific parser:
RUBY2JS_PARSER=prism bundle exec rake test
RUBY2JS_PARSER=parser bundle exec rake test
Lessons Learned
Translation layers preserve investment. Rather than rewriting handlers for a new AST format, Prism::Translation::Parser let me keep existing code while gaining Prism compatibility.
Edge cases cluster around synthetic nodes. Filters create AST nodes that don't exist in source code. These nodes lack source locations, which breaks assumptions in comment association and location-based lookups.
Test coverage enables confident refactoring. With 1302 passing tests, I could make structural changes knowing regressions would surface immediately.
What's Next
The immediate roadmap for Ruby2JS:
- New filters:
group_by,sort_by,max_by,min_bytransformations - ERB filter: Extract and generalize the converter from my showcase app
- Rails helpers filter:
dom_id,link_to, path helpers - Online demo update: Consider ruby.wasm or self-hosted options for Prism support in browser
The broader vision: make shared Ruby/JavaScript logic practical and maintainable. Write business logic once in Ruby, let tools generate JavaScript equivalents automatically.
The goal isn't just code generation—it's eliminating an entire class of bugs that arise from maintaining parallel implementations.
Ruby2JS is open source: github.com/ruby2js/ruby2js. The showcase application demonstrating ERB-to-JS conversion is at github.com/rubys/showcase.