yrby
Collaborative editing for Rails, backed by y-crdt (the Rust library behind Y.js). Your Rails server speaks the y-websocket sync protocol directly, so there's no separate Node process hosting the Y.js documents.
class DocumentChannel < ApplicationCable::Channel
include Y::ActionCable::Sync
on_load { |key| MyStore.load(key) }
on_change { |key, update| MyStore.append(key, update) }
def subscribed = sync_subscribed(params[:id])
def receive(data) = sync_receive(data, params[:id])
end
On the browser, use the ActionCableProvider from the
yrby-client npm package.
Integrates with any editor that includes Y.js support, such as Tiptap, ProseMirror
and Lexxy.
Usage
Install the gem and npm package:
gem install yrby-actioncable # depends on yrby
npm install yrby-client
What you get
- A thread-safe Ruby
Docyou can share across Ruby threads/fibers, and native CRDT work runs with the GVL released. - The y-websocket protocol (document sync plus awareness/presence) as a one-include ActionCable concern.
- Authoritative record-before-distribute semantics: each document change can be recorded durably before it goes out to anyone.
- Optional server-side reads:
Doc#read_textandDoc#read_mapreconstruct a document's contents in Ruby - no Node process - for search, exports, validation, or server-side rendering.
Scope
yrby binds just the part of y-crdt you need to sync and persist collaborative
documents - a Doc, awareness, and the y-websocket protocol primitives. By default
the Ruby side treats a document as opaque CRDT state: it applies updates, answers
sync handshakes, and records deltas without reaching into the contents - the browser
editor owns the document's shape. When you do need to look inside, Doc#read_text
and Doc#read_map reconstruct it server-side, in Ruby.
Durability and delivery
The surface is intentionally small, but the focus is durability, resiliency, delivery guarantees, correctness, and thread safety.
Towards that goal, yrby adds capabilities that stand out even in the Yjs ecosystem:
- Built-in update acknowledgement: the
ActionCableProviderinyrby-clientwill continue to send updates until an ack is received from the server.yrby-actioncableonly sends an ack when applying an update is successful. The goal is at-least-once delivery, and because CRDTs are idempotent a duplicate update is effectively a no-op. - Gap detection in document updates: before applying an update and sending an ack to the client,
yrbychecks whether the update results in any causal gap. Ie, an update comes through which depends on a previous update that is not yet present in the document. This can result in a document stuck with "pending" updates, which will never apply if the missing update is not sent. To avoid this,yrbydoes not apply the update, and starts a new y-protocol sync with the client. That will cause the client to synchronize its document with the server, sending through any updates that may have been missed
What about yrb?
yrb has a much larger interface that gives you most of the Yjs type system -
shared text, arrays, maps, XML - to build and query documents in Ruby. It was a great
inspiration for my use of Yjs in Ruby/Rails, and I originally considered building
on top of it. There are a few reasons I went with yrby instead:
yrbis largely unmaintained. It was built as an experiment for GitLab, and the original author mostly moved onto other projects.- It isn't thread-safe. It segfaults in a threaded environment (such as ActionCable...)
- It's a much larger set of features to maintain, which most people don't need. The vast majority of people manipulate Y.js documents in the browser, not from a server-side language.
Testing
Ruby and Rust unit tests cover the core. CI also runs the npm client tests and a Rails demo smoke slice against the real ActionCable stack. The demo includes heavier local suites for hostile input, crash recovery, multi-browser editing, AnyCable, and load testing. The benchmark note below is from a single laptop. Issues and PRs are welcome.
Install
# Core CRDT + protocol primitives:
gem "yrby"
# For the Rails/ActionCable server concern (Y::ActionCable::Sync):
gem "yrby-actioncable"
Requires Ruby 3.4 or newer. The release workflow builds precompiled gems for Ruby 3.4 and 4.0 across the supported Ruby platforms, with native smoke tests on Linux x86_64 and macOS arm64. Installing from a matching platform gem needs no Rust; a source build needs Rust.
To work on the gem itself:
git clone https://github.com/jpcamara/yrby
cd yrby
bundle install
bundle exec rake compile test
The rest of the dev setup, plus the demo, is in CONTRIBUTING.md.
Docs
- The ActionCable concern and a quickstart are below.
examples/actioncable-demo: a runnable Rails + Tiptap app with collaborative cursors, the AnyCable setup, a Postgres store, and the test/load suites.- CHANGELOG.md and CONTRIBUTING.md.
Editors
yrby syncs opaque Yjs updates, so it works with any editor that has a Yjs binding. The demo app runs four, and CI drives each one in real Chrome: concurrent typing with every keystroke accounted for, remote cursors, local-only undo, and byte parity between the server-side renderers and the editor's own serializer. Each page is a working integration to copy from:
| Editor | Yjs binding | Demo code |
|---|---|---|
| Tiptap (v2) | @tiptap/extension-collaboration |
app.js |
| Lexxy (Lexical) | lexxy-realtime |
lexxy.js |
| Rhino Editor (Tiptap 3) | @tiptap/extension-collaboration + -caret |
rhino.js |
| CodeMirror 6 | y-codemirror.next |
codemirror.js |
The demo also syncs plain Yjs shapes with no editor at all — a whiteboard
on a Y.Map, a kanban board on a Y.Array, a co-filled form — over the
same channel. The demo README's "Using this in your own app" section has
the integration recipe, and its NoteMaterializer shows how to render a
document to ActionText server-side with Y::Tiptap or Y::Lexxy.
Usage
Doc (Low-Level Document Sync)
require "y"
# Create docs
doc = Y::Doc.new # random client ID
doc = Y::Doc.new(12345) # specific client ID (used for CRDT identity)
# Encoding
doc.encode_state_vector # => current state vector
doc.encode_state_as_update # => full update (lossless: keeps pending)
doc.encode_state_as_update(sv) # => update diff against state vector
doc.compacted_state_update # => full update, gap-free (excludes pending)
# Applying updates
doc.apply_update(update_bytes) # apply raw V1 update
doc.pending? # => true if holding un-integrable pending structs
# Sync protocol
doc.sync_step1 # => SyncStep1 message (this doc's state vector)
doc.(data) # => [msg_type, sync_type, response]; answers a
# peer's SyncStep1 with an integrated-only
# SyncStep2 (never serves pending structs)
Reading document contents
Reconstruct a document server-side — search, exports, emails, SSR — with no Node process:
doc.read_text("prosemirror") # => plain text of a Y.Text root, or nil
doc.read_xml("root") # => text of an XML root, one block per line
doc.read_map("state") # => a Y.Map root as a JSON string; JSON.parse it
Pending structs and gap-free state
If a doc applies an update whose causally-prior update is missing (a "gappy"
update), yrs parks it as a pending struct: the integrated state vector stays
empty, but the pending block is held as a recovery buffer and heals if the
missing dependency later arrives. Doc#pending? reports this.
Pending structs are not document state, so they must not cross the sync boundary — a peer that receives one can't integrate it and gets stuck. Two guarantees keep serving safe:
handle_sync_messageanswersSyncStep1with integrated-only state, so a server never serves a struct it can't integrate itself (this is automatic).Doc#compacted_state_updategives you the same gap-free full-state update for when you persist or hand off state yourself. It's non-destructive (the doc keeps its pending), whileencode_state_as_updatestays lossless so you can still preserve the raw pending bytes for recovery.
Rendering to HTML
Schema-pinned renderers turn a collaborative document into HTML on the
server, with no Node process or headless editor. Each is an editor-specific
class (byte-for-byte with that editor's own serializer) built on a core base
any other editor extends with rules: Y::Tiptap on Y::ProseMirror for
ProseMirror documents, and Y::Lexxy (the
Lexxy editor) on Y::Lexical. Each
returns nil for a root that belongs to the other schema.
Y::Tiptap (and Y::ProseMirror, its base)
tiptap = Y::Tiptap.new(doc)
tiptap.to_html # the "default" fragment (Tiptap's default root)
tiptap.to_html("content") # or another XML root
The output matches Tiptap's own getHTML(), checked byte-for-byte in the tests
against a document captured from a real editor. It follows
tiptap-php and reads both name
styles editors use — Tiptap's bulletList/bold and prosemirror-schema-basic's
bullet_list/strong.
It covers paragraphs, headings, blockquotes, bullet/ordered/task lists, code
blocks, links, images, mentions, details, hard breaks, horizontal rules,
tables, text styles (color, font family), and every text mark. A table renders
as semantic <table><tbody>, without the column-width styling Tiptap's editor
view adds.
The support is layered like the Lexical side: Y::ProseMirror covers core
ProseMirror natively — prosemirror-schema-basic plus the prosemirror-tables
family — and Tiptap's extension nodes (task lists, mentions, the details
family) are Y::Tiptap's rule set (Y::Tiptap::NODES), built on the
extension API below. Marks stay in the base: mark rendering (nesting order,
textStyle CSS, code exclusivity) runs through native text-run machinery
that node rules don't reach, so Y::ProseMirror renders Tiptap's mark set
as-is and rules.mark overrides individual marks.
Y::Lexxy (and Y::Lexical, its base)
lexxy = Y::Lexxy.new(doc)
lexxy.to_html # the "root" fragment (Lexical's default root name)
lexxy.to_html("notepad") # or another XML root
The HTML is identical to what a lexxy-editor submits to Rails (its value).
The tests check this byte-for-byte against a document captured from a real
editor. Stock Lexical has no canonical serializer — every editor configures
its own — so the editor-specific class carries the editor's name, and
Y::Lexical is the core-Lexical base: paragraphs, headings, quotes, code,
lists, tables, links, and the full text-format model, for any other Lexical
editor to extend with rules.
It handles the whole Lexxy 0.9.x node set: paragraphs, headings, every text
format and their combinations, links, the four list types and nesting,
blockquotes, code blocks, tabs and soft breaks, horizontal rules, tables with
header cells, image galleries, and ActionText attachments (uploads and
mentions both emit <action-text-attachment> elements that ActionText can
re-render).
Internally that support is layered: Y::Lexical covers core Lexical
structure natively, and everything Lexxy adds — its node types (attachments,
galleries) and its decorations of core nodes (the table wrapper, header-cell
styling, nested-list classes) — is Y::Lexxy's rule set
(Y::Lexxy::NODES), built on the extension API below. The gem's own Lexxy
support is the API's first consumer: an app rule for one of those types
simply replaces it.
In both renderers an unknown node keeps its content — text and nested blocks fall back to readable markup rather than disappearing.
Custom nodes and marks
The built-in schemas are pinned to what Tiptap and Lexxy ship, but apps add their own node types. Both renderers take rules for them. A rule is checked before the built-in schema, so it can add a node type or replace how a built-in renders.
Rules register in a block — one rules.node call per type. A declarative
rule is markup as data, rendered natively:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "callout", tag: "aside",
attrs: { "class" => ["callout callout--", :kind] },
contains: :blocks
end
tag names the element. attrs values are templates: a string is a literal,
a symbol reads that attribute off the node, an array concatenates both kinds;
an attribute that resolves empty is left out. text (same template form)
emits literal text content. contains declares what lives inside the node — :inline (formatted text,
the default), :blocks (child block nodes — a container), or :none (a
leaf). void: true skips the closing tag.
You don't have to guess any of those names or shapes. Editors store types
and attributes under names you'd never predict (Rhino's strike mark is
rhino-strike; Lexical prefixes its own props __), so ask a real
document instead — make one in your editor using your custom node, then:
Y::Tiptap.new(doc).node_types
# => { "callout" => { "count" => 2, "attrs" => ["kind"],
# "children" => ["paragraph"], "text" => false,
# "handled" => nil },
# "paragraph" => { ..., "handled" => "builtin" } }
handled nil marks the types that still need a rule; attrs are the stored
names your templates and blocks will read; children plus text is how you
pick contains: (child block types → :blocks; text → :inline).
When markup-as-data isn't enough, give the node a block:
lexical = Y::Lexical.new(doc) do |rules|
rules.node "video_embed" do |node|
src = ERB::Util.html_escape(node.attrs["__src"])
%(<video controls src="#{src}"></video>)
end
end
The block gets the node's type, its stored attributes, node.content — the
children, already rendered to HTML — and node.child_types, the node's
element/block children by type, in document order. child_types answers the
structural questions attributes can't: how many images a gallery holds, or
whether a list item carries a nested list. Whatever the block returns is
spliced into the output as-is: it's trusted HTML, so escape any values you
interpolate. To set the content mode for a callback, give the node both —
rules.node "embed", contains: :blocks do |node| ... end.
Callbacks never run while the document is locked. The render finishes first
(inside one read transaction, GVL released), then the blocks run and their
output is spliced in — so a callback can safely read or even write the same
doc. With no callback rules, to_html skips the splicing entirely.
Blocks are the escape hatch for everything the declarative form can't say,
and they're proven sufficient: Y::Lexxy and Y::Tiptap are themselves
built on this API (lib/y/lexxy.rb, lib/y/tiptap.rb) — simple nodes as
declarative hashes, everything with logic as plain methods mapped by node
type (a Method responds to call like any lambda) — and the fixture tests
hold their output byte-identical to a live editor's.
The ProseMirror side also takes custom marks:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.mark "comment", tag: "span", attrs: { "data-comment-id" => :id }
end
Symbol refs resolve against the mark's own attributes. A custom mark wraps
outside every built-in mark; several on one run nest alphabetically. A rule
for a built-in mark name ("bold") replaces its built-in tag.
Worked examples
A video-embed node from an app's Tiptap extension — a type the pinned schema has never heard of:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "videoEmbed" do |node|
src = ERB::Util.html_escape(node.attrs["src"])
title = ERB::Util.html_escape(node.attrs["title"] || "Video")
%(<figure class="video"><iframe src="#{src}" title="#{title}" allowfullscreen></iframe></figure>)
end
end
Resolving mentions against the database. Blocks run after the document read has finished, so hitting ActiveRecord (or the doc itself) inside one is safe:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "mention" do |node|
user = User.find_by(id: node.attrs["id"])
next "<span>@unknown</span>" unless user
%(<a class="mention" href="/users/#{user.id}">@#{ERB::Util.html_escape(user.handle)}</a>)
end
end
Overriding a shipped rule — rendering Lexxy uploads as real image markup
instead of the <action-text-attachment> elements ActionText re-renders:
lexxy = Y::Lexxy.new(doc) do |rules|
rules.node "action_text_attachment" do |node|
src = ERB::Util.html_escape(node.attrs["src"])
alt = ERB::Util.html_escape(node.attrs["altText"].to_s)
caption = node.attrs["caption"].to_s
html = %(<img src="#{src}" alt="#{alt}" loading="lazy">)
html += "<figcaption>#{ERB::Util.html_escape(caption)}</figcaption>" unless caption.empty?
"<figure>#{html}</figure>"
end
end
Markup that depends on structure — node.child_types lists the node's
element/block children in document order, so a layout container can size
itself by its column count while the columns themselves stay declarative:
tiptap = Y::Tiptap.new(doc) do |rules|
rules.node "columns", contains: :blocks do |node|
%(<div class="columns columns--#{node.child_types.length}">#{node.content}</div>)
end
rules.node "column", tag: "div", attrs: { "class" => "column" }, contains: :blocks
end
Content-aware overrides — dropping the empty paragraphs an editor keeps
around the cursor, since node.content arrives already rendered:
lexical = Y::Lexical.new(doc) do |rules|
rules.node "paragraph" do |node|
node.content.empty? ? "" : "<p>#{node.content}</p>"
end
end
For a larger reference, the gem's own editor schemas ship this way — see
Y::Lexxy::NODES in lib/y/lexxy.rb (declarative hashes for the simple
nodes, a plain method per node that needs logic — galleries, list items,
header cells, both attachment types — mapped with method(:name)) and
Y::Tiptap::NODES in lib/y/tiptap.rb (task lists, mentions, the details
family).
Protocol codec (module functions)
Classifying and unwrapping wire frames is stateless, so it's exposed as
Y module functions rather than a class. The server never holds presence
or document state to route a frame — presence lives in the browser clients, and
the server only relays awareness frames opaquely.
Y.(frame) # => 0 drop / 1 step1 / 2 update / 3 awareness / 4 query
Y.(frame) # => the document delta carried by a frame, or nil
Y.wrap_update(update_bytes) # => wrap a raw doc update as a sync Update frame
ActionCable Integration
Y::ActionCable::Sync (from the yrby-actioncable gem) is a channel
concern that implements the full y-websocket protocol (document sync +
awareness/presence) over ActionCable:
# app/channels/document_channel.rb
class DocumentChannel < ApplicationCable::Channel
include Y::ActionCable::Sync
on_load { |key| MyStore.load(key) } # source of truth
on_change { |key, update| MyStore.append(key, update) } # durable record
def subscribed
sync_subscribed params[:id]
end
def receive(data)
sync_receive(data, params[:id])
end
end
The concern is store-backed. A handshake is answered from on_load; document
changes are checked against that durable state, recorded through on_change,
then broadcast. Nothing authoritative is kept in ActionCable process memory, so
AnyCable RPC workers, Puma workers, and separate dynos can all handle messages
for the same document as long as they share the same store and cable adapter.
on_load and on_change are required. If either is missing, the channel fails
before it can acknowledge or broadcast edits. Presence is ephemeral:
awareness frames are relayed, and yrby-client sends a best-effort
presence-removal frame on disconnect/pagehide, with the client-side awareness
timeout as the fallback for abrupt disconnects.
Incoming frames are validated as a single well-formed protocol message before anything processes or relays them. Malformed, truncated, multi-message, oversized, or unknown frames are dropped. A bad frame can't crash the process: a Rust panic is caught at the FFI boundary and re-raised as a Ruby exception. And no single client can relay garbage that breaks the others in a room.
Delivery guarantees
The contract is the same at every scale — one process, or hundreds across many servers:
- The document always converges. CRDT updates are commutative and idempotent, so out-of-order, duplicate, or concurrent delivery all converge to the same correct document. This needs no coordination and holds everywhere.
- The durable log never goes gappy. An update is recorded only once its
causal dependencies are already in the store (checked against
on_load); a causally-incomplete update triggers a resync instead, so the log always rebuilds cleanly. on_changeis at-least-once, and the durable guarantee is that replaying the log reconstructs the document. Every update triggerson_changebefore it's acked or broadcast (record-before-distribute). If exactly-once updates matter for you, you must makeon_changeidempotent. But remember that the CRDT can handle duplicates.- A raising
on_changerejects the update implicitly. If the block raises, the update is neither acked nor broadcast (record-before-distribute stops both). There is no negative-ack: the client simply never receives the ack, keeps the update pending, and retransmits on its timer/reconnect. This is built for transient failures (the store is briefly down → a retry lands). A block that raises deterministically — a validation that always fails for this edit — will be retried forever, since nothing tells the client to stop. Enforce hard rejections before the edit reacheson_change(channel authorization insubscribed), not by raising inside it. - An over-cap frame is dropped the same silent way. A frame larger than
max_frame_bytes(default 8 MiB) is dropped before decoding — no ack, no broadcast — to bound the work a client can force. For a genuine document update that means the same implicit rejection as above: unacked, retransmitted forever. Normal typing never approaches the cap, but a large paste, an embedded image, or a big initialSyncStep2can. The drop is logged (warnfor over-cap,debugfor undecodable) with the document key and update id so it's findable; overridesync_log_contexton the channel to add a user/connection id. Size the cap for your largest expected payload, and reject genuinely-too-big content upstream rather than relying on the cap to reject it gracefully.
Multi-process deployments
Most Rails apps run several processes, and any of them might serve a given document. Two pieces keep them in step.
Broadcasts cross processes through the Action Cable adapter, so it needs to something
like redis or solid_cable, not async. With that in place, a change
on one process reaches clients on all of them.
Every process rebuilds document state from the durable store through on_load.
Because changes are recorded before broadcast, record-before-distribute holds
across processes: whichever process receives a change records it to the shared
store before anyone, anywhere, sees it.
bun multiprocess.mjs in the demo runs clients across two processes and checks
convergence, fresh reads on both, presence across processes, and one shared log.
AnyCable
yrby fully supports AnyCable.
The demo checks this against a real anycable-go + RPC server
(frontend/anycable_probe.mjs, anycable_concurrent.mjs): liveness, the
yrby client provider, cross-process reads, and concurrent convergence.
Demo
examples/actioncable-demo is a full Rails + Tiptap
app using the yrby provider, with end-to-end tests.
Record Before Distribute
Every document change is handed to the on_change handler before broadcasting.
It is up to you to durably record it:
class DocumentChannel < ApplicationCable::Channel
include Y::ActionCable::Sync
# ...
on_change do |key, update|
# Synchronous, durable write. `update` is the exact CRDT delta.
AuditLog.append!(key, update) # raise to REJECT the change
end
# ...
end
If the recorder raises (say the store is down), the change is rejected: not applied, not sent to anyone. The cost is a synchronous durable write on the path of every change. There's no in-gem per-document lock; concurrent writes to one document can both record (at-least-once), and since CRDT apply is idempotent a duplicate record replays to the same document.
The demo wires on_change to a durable Postgres-backed log by default, and checks
end to end that the log alone rebuilds the document.
Reliable delivery (acks)
yrby document delivery is ack-tracked. Browser document updates carry an
"id", and the server replies { "ack": <id> } once on_change has succesfully fired.
A causally-gapped update is not acked; the server sends a resync request, and
the client keeps the update queued until it lands.
client -> server { "update": "<base64 update>", "id": 42 }
server -> client { "ack": 42 } # update accepted; safe to forget
yrby-client's ActionCableProvider handles this automatically. It keeps
the unacknowledged local document tail in a queue and sends the merged tail as a
single causally-complete delta. The id is the highest sequence in the batch, so
one { ack: id } cumulatively confirms everything up to it. Because CRDT apply
is idempotent, a resend that already landed is a harmless no-op that just
re-acks. Awareness stays ephemeral and is not acked.
Presence (cursors, selections) is owned by the browser clients — the server
never sets or holds presence state, it only relays awareness frames opaquely.
See yrby-client for the client-side awareness API.
Thread Safety
A Doc is safe to share across Ruby threads — used concurrently from Puma
workers, ActionCable connection threads, or background jobs without external
locking.
test/thread_safety_test.rb runs shared docs, the full sync handshake, and
fan-in sync across 8 threads at once, and checks the interleaving doesn't change
convergence.
Parallelism (GVL release)
Every method that does real CRDT work (applying updates, encoding state,
handling sync messages) releases Ruby's Global VM Lock
(rb_thread_call_without_gvl) while the native code runs. That buys two things.
CRDT work runs in parallel across Ruby threads on MRI, not just
JRuby/TruffleRuby. bench/parallelism_bench.rb measures over 2x wall-clock
speedup applying a ~900 KB update concurrently; native code that held the GVL
couldn't beat serial time.
A slow operation also can't stall the VM. A thread applying a large update holds the doc's write lock without holding the GVL, so other Ruby threads keep running instead of queuing behind it.
Each method has the same shape: copy Ruby byte strings first, drop the GVL, do the yrs work while taking and releasing native locks entirely inside the closure, take the GVL back, then build Ruby objects. No Ruby API is touched without the GVL, and no native lock is held while reacquiring it, so the lock order can't deadlock. Panics in native code are caught and re-raised as Ruby exceptions.
Message Type Constants
Y::MSG_SYNC # 0 - Document sync messages
Y::MSG_AWARENESS # 1 - User presence data
Y::MSG_SYNC_STEP1 # 0 - State vector request
Y::MSG_SYNC_STEP2 # 1 - Update response
Y::MSG_SYNC_UPDATE # 2 - Incremental update
Sync Flow
Client A Server
| |
|-------- connect() ------------->|
| (SyncStep1 + Awareness) |
| |
|<--- handle_sync_message resp ---|
| (SyncStep2) |
| |
| (Document synchronized!) |
| |
|<------- updates ----------------|
|-------- updates --------------->|
Development
# Setup
bundle install
# Build extension
rake compile
# Run tests
rake test
# Clean build artifacts
rake clean
License
MIT License
Acknowledgments
- y-crdt/yrs - The Rust implementation of Y.js
- Magnus - Ruby bindings for Rust
- rb-sys - Rust extensions for Ruby