turbo.cr
turbo.cr
A 1:1 Crystal port of turbo-rails 2.0.20 (Hotwire Turbo Streams) with the namespace Turbo, built on top of action_view.cr, action_pack.cr, action_cable.cr, active_record.cr and active_job.cr. Covers the stream tag builder (turbo_stream.append/prepend/...), cable broadcasting (broadcast_*_to, declarative broadcasts/broadcasts_to on models), _later jobs over ActiveJob, view helpers (turbo_stream_from, turbo_frame_tag) and the text/vnd.turbo-stream.html HTTP format. Client side is the official vendored @hotwired/turbo 8.0.13 UMD plus a ~60-line cable connector instead of the npm bundle. Suite: 60/60 oracle cases recorded from the real gem + an E2E smoke of the demo app.
⚠️ EXPERIMENTAL FRAMEWORK, DO NOT USE IN PRODUCTION YET.
Installation
dependencies:
turbo_cr:
github: OrelSokolov/turbo.cr
shards install
Depends on active_support.cr, action_view.cr, action_pack.cr, action_cable.cr, active_record.cr and active_job.cr.
Usage
# In a view or turbo_stream template:
turbo_stream.append "entries", "<div>hi</div>"
turbo_stream.remove "clearance_5"
turbo_stream.update "entries", partial: "entries/entry", locals: {"entry" => entry}
turbo_stream.refresh
turbo_frame_tag "cart", src: "/cart", loading: "lazy" { }
turbo_stream_from "room", "entries"
# From a model or controller:
Turbo::StreamsChannel.broadcast_prepend_to ["entries"],
target: "entries", content: "<div>new</div>"
class Message < ActiveRecord::Base
include Turbo::Broadcastable
broadcasts_to :board, inserts_by: :prepend, target: "board_messages"
end
Feature comparison vs turbo-rails 2.0.20
Streams tag builder & view helpers
| Feature | Original (turbo-rails) | Port (turbo.cr) | Status |
|---|---|---|---|
turbo_stream actions append/prepend/replace/update/remove/before/after (+ _all variants) |
✅ | Same 7 actions, block/no-block overloads | ✅ |
turbo_stream.refresh, custom action/action_all |
✅ | Same | ✅ |
Rendering semantics: positional content unescaped, block/html escaped, method: before action/target |
✅ | Same (locked by oracle) | ✅ |
turbo_frame_tag (+ block form, content_tag escaping) |
✅ | Same | ✅ |
turbo_stream_from (signed stream name, custom channel) |
✅ | Same (raise "streamables can't be blank" locked by oracle) |
✅ |
renderable: objects |
✅ | P2, not implemented | ❌ |
Broadcasting
| Feature | Original | Port | Status |
|---|---|---|---|
broadcast_*_to for all actions + broadcast_action_to, broadcast_stream_to, broadcast_render_to |
✅ | Same | ✅ |
broadcast_refresh_to (no attributes in 2.0.20) |
✅ | Same (oracle pins the no-attributes behavior) | ✅ |
Signed stream names (signed_stream_name/verified_stream_name, HEX digest) |
✅ | Same via ActiveSupport::MessageVerifier | ✅ |
Turbo::Broadcastable: broadcasts/broadcasts_to macros (after_commit create/update/destroy), instance broadcast_*, suppressing_turbo_broadcasts, GlobalID-lite |
✅ | Same | ✅ |
Proc streams in broadcasts_to |
✅ | P2, Symbol (model method) or String literal only | ⚠️ partial |
Debounce in broadcast_refreshes_with |
✅ | P1, not implemented | ❌ |
Jobs, channel & HTTP format
| Feature | Original | Port | Status |
|---|---|---|---|
Turbo::StreamsChannel subscription by signed stream name |
✅ | Same (rejects invalid signatures) | ✅ |
_later jobs: ActionBroadcastJob / BroadcastJob / BroadcastStreamJob |
✅ | Same via ActiveJob (payload-hash dispatch, ctors up to 4 args) | ✅ |
respond_to { format.turbo_stream }, default #{action}.turbo_stream.erb render without layout, CT text/vnd.turbo-stream.html; charset=utf-8 |
✅ | Same (in action_pack.cr), Accept negotiation + ?format=turbo_stream |
✅ |
redirect_to from a turbo_stream request → http fallback with CT text/html |
✅ | Same | ✅ |
| Client JS (npm bundle) | ✅ | Vendored @hotwired/turbo 8.0.13 UMD + ~60-line turbo_cable.js WS connector |
⚠️ partial |
Turbo::Frames frame_loading / enforce_frame_routes |
✅ | No routing-port analog | ❌ |
Deviations & Crystal-specific notes
- kwargs → named args:
turbo_stream.append target, "text"instead of**options. - Renderer seam instead of
ApplicationController.render: compiled languages can't render from a string at runtime — the app registersTurbo.partial_renderer/Turbo.template_rendererprocs over compile-time-compiled partials/templates. Seespec/oracle_spec.cr(TurboView) and demo (src/turbo_renderers.cr) for boilerplate. broadcastsmacro: pluralize is a runtime inflector, unavailable in macro-land — the stream name is computed at runtime.- Block overloads are typed (
&block : Proc(String)) because untyped blocks passed through as Proc return nil; locals are normalized viaTurbo.cast_locals(generics invariance); ActiveJob argument hashes are strictlyHash(String | Symbol, ArgValue). - Declarative macros live in the
Broadcastablemodule body (plain methods in ClassMethods viamacro included) becauseextendinsidemacro includeddoesn't hoist macros.
Testing
make spec # = CRAILS_VIEWS=spec/fixtures/views crystal spec
Requires a local PostgreSQL (default postgres://root@/crails_turbo_spec?host=/var/run/postgresql, override with TURBO_SPEC_URL); Redis is not needed (in-process Test subscription adapter). Oracle: 60 cases recorded from the real turbo-rails 2.0.20 + rails 8.1.4 (DB crails_turbo_oracle, TZ=UTC) in oracle/cases.rb → oracle/expected.yml; the Crystal mirror spec/oracle_spec.cr replays them 1:1 without edits (expected raise cases included). E2E smoke spec/e2e_smoke.cr additionally needs the demo app running: demo/bin/app serve -p 3001.
turbo.cr
- 0
- 0
- 0
- 0
- 0
- about 5 hours ago
- October 12, 2026
Mon, 12 Oct 2026 06:14:25 GMT