yrby

lexxy-realtime · a separate gem, built on yrby

Collaborative editing for Lexxy

Lexxy is Basecamp's rich text editor for Action Text, built on Lexical. lexxy-realtime lets several people type in the same field and see each other's text and cursors as they go. You add one line to the model and one to the form, then run a generator.

lexxy-realtime installs yrby-rails and yrby-client for you and does the editor setup. If you use a different editor, or no editor, use yrby-rails directly.

app/models/post.rbmodel
class Post < ApplicationRecord  has_collaborative_rich_text :bodyend
app/views/posts/_form.html.erbview
<%= form.collaborative_rich_textarea :body %>
terminal
bin/rails generate lexxy_realtime:install && bin/rails db:migrate

Import lexxy-realtime wherever you import Lexxy. The channel ships in the gem, so there's no channel to write. Render the form only for users who may edit the record, then open it in two browsers and type.

post.body is still Action Text.

While people are editing, lexxy-realtime keeps the body's Yjs state in yrby's Y::Document tables. After each change, it renders that state to HTML in Ruby with Y::Lexxy and saves the result to post.body. That HTML matches what Lexxy would have submitted itself, so search, mailers, and show pages work without changes.

Watch the saved HTML update as you type →

The channel ships in the gem.

The form helper wraps the Lexxy editor in yrby-client's <yrby-document> element and puts a <lexxy-collaboration> element inside the editor:

<yrby-document grant="..." name="body" channel="LexxyRealtime::DocumentChannel">
  <lexxy-editor>
    <lexxy-collaboration doc-id="post-42-body" name="Ada" color="#3b82f6">
    </lexxy-collaboration>
  </lexxy-editor>
</yrby-document>

<yrby-document> subscribes to the channel with the signed grant and holds the post's Yjs document, the provider, and any edits the server hasn't confirmed yet. When the document first syncs, <lexxy-collaboration> binds the editor to it. The editor stays inert until then, so nobody types into a document that can't sync.

The grant is a signed GlobalID for one record and one field. LexxyRealtime::DocumentChannel extends yrby-rails' Y::DocumentChannel and looks up the record from the grant. It rejects a grant that's missing, tampered with, expired, or made for another field, a record that's been deleted, and a field that isn't declared with has_collaborative_rich_text. The grant has its own purpose, so Y::DocumentChannel rejects it, and a client can't skip the Action Text rendering by subscribing to the parent channel.

A valid grant means your app rendered the form for this user. To also check the user's current permissions when they subscribe, give the channel an authorize_document block. It runs inside the channel, so current_user and your other connection identifiers work. If it returns false or nil, the channel rejects the subscription before it sends anything.

# config/initializers/lexxy_realtime.rb
Rails.application.config.to_prepare do
  LexxyRealtime::DocumentChannel.authorize_document do |record, name|
    record.editable_by?(current_user, attribute: name)
  end
end

Grants expire, and the page can renew them.

A grant lasts a month by default, which is GlobalID's default under Rails. Pass expires_in: to shorten it. The grant is part of the rendered page, and Action Cable resubscribes with it after every network drop, so an editor whose grant expired stops syncing at its next reconnect. Pair expires_in: with refresh:, the URL of an action that returns a new grant:

<%= form.collaborative_rich_textarea :body, expires_in: 10.minutes,
                                             refresh: grant_post_path(@post) %>
# config/routes.rb: resources :posts do get :grant, on: :member end
def grant
  @post = current_user.posts.find(params[:id]) # your own authorization, again
  render json: { grant: @post.collaborative_rich_text_grant(:body, expires_in: 10.minutes) }
end

When the server rejects the subscription, <yrby-document> fetches that URL with the session cookie and resubscribes with the new grant. It keeps the Yjs document and any edits the server hasn't confirmed. The action hands out write access, so its check has to be at least as strict as the page that renders the form. If the refresh fails, the editor stops syncing until the page reloads.

Import maps or a bundler.

With import maps, the generator pins the JavaScript files the gem ships, one for each module the page must load only once. It keeps pins your app already has, so it's safe to run again after an upgrade.

# config/importmap.rb, added by the generator
pin "@37signals/lexxy", to: "lexxy.js"
pin "lexxy-realtime", to: "lexxy_realtime/lexxy-realtime.js"
pin "yrby-client", to: "lexxy_realtime/yrby-client.js"
pin "yrby-client/element", to: "lexxy_realtime/yrby-client.js"
pin "yjs", to: "lexxy_realtime/yjs.js"
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "@rails/activestorage", to: "activestorage.esm.js"

@37signals/lexxy points at Lexxy's own file, and lexxy-realtime gets Lexical from it, so the page loads one copy of each. yrby-client and Yjs are separate files, so any of your own code that imports them gets the same copies lexxy-realtime uses. With a bundler, run npm install lexxy-realtime. npm and bun install its peers (lexical, yjs, and the rest). Either way, the entry point imports the same two packages:

import "@37signals/lexxy"
import "lexxy-realtime" // registers <lexxy-collaboration> and <yrby-document>

<yrby-document> creates an @rails/actioncable consumer from the page's action-cable-url meta tag. To use @anycable/web, set the consumer once at boot, before any editor mounts. The function runs the first time an editor needs a consumer:

import { createConsumer } from "@anycable/web"
import { setConsumer } from "lexxy-realtime"

setConsumer(() => createConsumer())

Custom nodes and encryption.

Any custom Lexical nodes you've registered sync like the rest of the editor's content. When Y::Lexxy doesn't know a node, it renders what it can: a container or inline wrapper keeps its text, and an embed that keeps its content in attributes renders nothing. Pass nodes: to has_collaborative_rich_text with a render rule for each of your node types. When a document has node types with no rule, the gem logs a warning that names them.

To encrypt a field, pass encrypted: true to has_collaborative_rich_text. Both the Action Text body and the Y::Document rows are then stored with Active Record encryption.

Built on yrby.

lexxy-realtime is a small layer on top of yrby, which the rest of this site documents. yrby does the syncing, the saving, and the HTML rendering, over Action Cable or AnyCable. The gem's channel is yrby-rails' Y::DocumentChannel plus the Action Text rendering, and the <yrby-document> element comes from yrby-client.

bundle add lexxy-realtime  ·  bin/rails generate lexxy_realtime:install