lexxy-realtime 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e13daa738b30059ffd6512349184322d7c5866238a6bf886b60d55ce15006f15
4
- data.tar.gz: 91e37a272447251a7c3f8b0a409c9e7264be7958da2bfebd088a059d94d737af
3
+ metadata.gz: 992aeb83b159a49fae25e6e6897b120e288ad7f497352aa0580cc76d0d548d4e
4
+ data.tar.gz: aa54afbad36600e60da26c0db20bd619381bcf8ee499817ef5111dbfa32cfd91
5
5
  SHA512:
6
- metadata.gz: b94ee6e879b9aebe10bd52c2fb296afc6e29242d8929c7082434ae4d653d3b9fc5ad3a2e598ad871c9dd7d0e525d411c6f5c9e0d22f1280bde6718da4c360635
7
- data.tar.gz: 86deff4da480f6e97d13aa130d545e6625d246a05472792a3c9bcb07f2ee6fbbbc0f4b9a4a5c46b867666c6279d0f30ca906319c2ef20711799cd9df254d5545
6
+ metadata.gz: 89100b1162553474b8ee6f795d9f782b7ca1623a9ae62d77460b9a1edd1411d8b23fd404a16e114dcbe289b0091153a10f7ccfdc8c66dec089203a283e351dad
7
+ data.tar.gz: eb7eed40ea71ebd9fe6b8ce872e8b18373ad3f34a352468a7a9f4fffd06d5cd9cd23d255313a95b2afd74bf3a452b47c13afb49b4ed3a64245fbe04e0a538697
data/README.md CHANGED
@@ -21,10 +21,10 @@ bin/rails generate lexxy_realtime:install
21
21
  bin/rails db:migrate
22
22
  ```
23
23
 
24
- The generator creates `app/channels/document_channel.rb`, installs
25
- yrby's table migration, and adds the standard Action Cable files when
26
- they are missing. The `Y::Document` and `Y::DocumentUpdate` models come
27
- from `yrby-rails`.
24
+ The generator adds yrby's table migration. If the app uses import maps, it
25
+ also adds pins. The channel, `LexxyRealtime::DocumentChannel`, ships in the
26
+ gem. The `Y::Document` and `Y::DocumentUpdate` models come from
27
+ `yrby-rails`.
28
28
 
29
29
  With a bundler, install the JavaScript package and import it next to
30
30
  your Lexxy import:
@@ -33,24 +33,32 @@ your Lexxy import:
33
33
  npm install lexxy-realtime # yarn, bun, and pnpm also work
34
34
  ```
35
35
 
36
- With import maps, there is no npm install; the generator pins assets
37
- this gem ships:
36
+ With import maps, there's no npm install. The generator pins assets this
37
+ gem ships, one for each module the page must load only once:
38
38
 
39
39
  ```ruby
40
40
  # config/importmap.rb, added by the generator
41
- pin "lexical", to: "lexxy_realtime/lexical.js"
42
- pin "@37signals/lexxy", to: "lexxy_realtime/lexxy.js"
41
+ pin "@37signals/lexxy", to: "lexxy.js"
43
42
  pin "lexxy-realtime", to: "lexxy_realtime/lexxy-realtime.js"
43
+ pin "yrby-client", to: "lexxy_realtime/yrby-client.js"
44
+ pin "yrby-client/element", to: "lexxy_realtime/yrby-client.js"
45
+ pin "yjs", to: "lexxy_realtime/yjs.js"
46
+ pin "@rails/actioncable", to: "actioncable.esm.js"
44
47
  pin "@rails/activestorage", to: "activestorage.esm.js"
45
48
  ```
46
49
 
47
- `lexical` is the one module the Lexxy and lexxy-realtime bundles share,
48
- so both ship with it external and it resolves through its own pin. The
49
- `@37signals/lexxy` pin must point at this gem's build: Lexxy's own
50
- asset bundles a second copy of `lexical`, and two copies break the
51
- collaboration binding, so remove any pin of Lexxy's asset. Keep
52
- `stylesheet_link_tag "lexxy"` for the editor's CSS. These assets are a
53
- stopgap until Lexxy ships import-map-ready builds itself.
50
+ `@37signals/lexxy` points at the same file as the Lexxy gem's own `lexxy`
51
+ pin, so the browser loads Lexxy once. lexxy-realtime gets lexical from
52
+ Lexxy's `Lexical` export, so there's also one copy of lexical.
53
+
54
+ yrby-client and yjs are separate files. A second copy of either would mean
55
+ a second `<yrby-document>` class or a second Yjs, and the editor binding
56
+ would break. With separate pins, any other code that imports them gets the
57
+ same copy as lexxy-realtime. `@rails/actioncable` is Rails' own file, and
58
+ `<yrby-document>` uses it for its default consumer.
59
+
60
+ The generator keeps pins the app already has and adds the missing ones, so
61
+ run it again after upgrading.
54
62
 
55
63
  Either way, your entrypoint imports both:
56
64
 
@@ -73,10 +81,13 @@ end
73
81
  <% end %>
74
82
  ```
75
83
 
76
- Add your app's access check to `authorized?` in the generated channel,
77
- then open the page in two browsers and edit together. The record must be persisted
78
- (the document key derives from it). A record with an existing body works: the
79
- first collaborative open seeds the document from it.
84
+ Render the form only for users who may edit the record, then open the page
85
+ in two browsers and edit together. You don't write a channel. The helper
86
+ renders a signed grant for the record and field, and
87
+ `LexxyRealtime::DocumentChannel` opens a document only for a valid grant.
88
+ The record must be saved first, because the document key comes from its id.
89
+ If the record already has a body, the first editor to open it copies that
90
+ body into the new document.
80
91
 
81
92
  Encryption works the way Action Text's does:
82
93
 
@@ -84,37 +95,109 @@ Encryption works the way Action Text's does:
84
95
  has_collaborative_rich_text :body, encrypted: true
85
96
  ```
86
97
 
87
- The rendered body goes through `ActionText::EncryptedRichText`, and the
88
- collaborative document (CRDT state and update payloads) is stored through
89
- yrby's `Y::EncryptedDocument`. Both use Active Record encryption, so the
90
- app must configure encryption keys. Without Action Text, declare
91
- `encrypts` on the plain attribute yourself.
98
+ Action Text stores the rendered body with `ActionText::EncryptedRichText`.
99
+ yrby's `Y::EncryptedDocument` stores the collaborative document, both the
100
+ CRDT state and each update. Both use Active Record encryption, so the app
101
+ needs encryption keys configured. Without Action Text, declare `encrypts`
102
+ on the plain attribute yourself.
92
103
 
93
- Use it for new attributes. Existing plaintext rows need migration
94
- before you add `encrypted: true`: enable `support_unencrypted_data`,
95
- rewrite each document, update, and rich-text row through its encrypted
96
- class, then turn it back off. There is no built-in task for that yet.
97
- And if your channel came from an earlier pre-release checkout, update it
98
- to the current record-based storage first; a channel calling
99
- `Y::Document` directly stores encrypted attributes as plaintext.
104
+ This works best on new attributes. To encrypt an attribute that already
105
+ has plaintext rows, enable `support_unencrypted_data`, rewrite each
106
+ document, update, and rich-text row through its encrypted class, then
107
+ turn the setting off again. The gem doesn't include a task for this.
100
108
 
101
109
  ## How the body stays current
102
110
 
103
- The channel records each CRDT update, renders the full document with
111
+ The channel saves each CRDT update, renders the full document with
104
112
  `Y::Lexxy`, and saves the HTML through the Action Text writer. This
105
- happens synchronously in `refresh_collaborative_rich_text`, so
106
- reads use the stored `post.body` value.
113
+ happens in `refresh_collaborative_rich_text`, inside the channel's handler
114
+ for each update. There's no background job, so `post.body` is up to date as
115
+ soon as the channel finishes handling the update.
107
116
 
108
- If rendering fails, the update remains stored and the error is logged.
109
- The next successful update renders the full document again. Until then,
110
- `post.body` keeps its previous value.
117
+ If rendering fails, the channel logs the error and keeps the update. The
118
+ next update renders the full document again. Until then, `post.body`
119
+ keeps its previous value.
111
120
 
112
121
  ## Access control
113
122
 
114
- The form helper gives clients a signed GlobalID scoped to one record and
115
- field. `DocumentChannel` uses it to locate the record. Put the user
116
- access check in `authorized?` (for example,
117
- `record.editable_by?(current_user)`).
123
+ The form helper renders a signed grant for the record and field.
124
+ `LexxyRealtime::DocumentChannel` extends yrby-rails' `Y::DocumentChannel`
125
+ and adds Action Text rendering. The grant uses its own purpose, so
126
+ `Y::DocumentChannel` rejects it. A client can't get around the block below
127
+ by subscribing to that channel. The channel rejects a missing, tampered,
128
+ expired, or wrong-field grant, a deleted record, and a field that isn't
129
+ declared with `has_collaborative_rich_text`. A valid grant means your app
130
+ rendered the form for this user.
131
+
132
+ To also check the user's current permissions when they subscribe, give
133
+ the channel a block:
134
+
135
+ ```ruby
136
+ # config/initializers/lexxy_realtime.rb
137
+ Rails.application.config.to_prepare do
138
+ LexxyRealtime::DocumentChannel.authorize_document do |record, name|
139
+ record.editable_by?(current_user, attribute: name)
140
+ end
141
+ end
142
+ ```
143
+
144
+ The block runs inside the channel, so you can use `current_user` and your
145
+ other connection identifiers. `editable_by?` is a placeholder for your own
146
+ permission check. If the block returns false or nil, the channel rejects
147
+ the subscription before it sends anything.
148
+
149
+ ## Grant lifetime and refresh
150
+
151
+ A grant lasts as long as GlobalID's signed-id default, which is one month
152
+ under Rails, and `expires_in:` on the form helper shortens it. The grant is
153
+ part of the rendered page, though, and Action Cable resubscribes with it
154
+ after every network drop. If the grant expires before the user is done
155
+ editing, the editor stops syncing at the next reconnect. To avoid that,
156
+ pair `expires_in:` with `refresh:`, a URL the page fetches when a
157
+ subscription is rejected:
158
+
159
+ ```erb
160
+ <%= form.collaborative_rich_textarea :body, expires_in: 10.minutes,
161
+ refresh: grant_post_path(@post) %>
162
+ ```
163
+
164
+ ```ruby
165
+ # config/routes.rb: resources :posts do get :grant, on: :member end
166
+ # app/controllers/posts_controller.rb
167
+ def grant
168
+ @post = current_user.posts.find(params[:id]) # your own authorization, again
169
+ render json: { grant: @post.collaborative_rich_text_grant(:body, expires_in: 10.minutes) }
170
+ end
171
+ ```
172
+
173
+ `collaborative_rich_text_grant` makes the same grant the form helper
174
+ renders, for `LexxyRealtime::DocumentChannel`. A grant from yrby-rails'
175
+ `collaborative_sgid` doesn't work here, because the channel checks a
176
+ different purpose.
177
+
178
+ That action grants write access, so its check must be at least as strict
179
+ as the page that renders the form. If it skips authorization, anyone who
180
+ can reach the URL gets a grant, and a short `expires_in:` protects nothing.
181
+
182
+ When a subscription is rejected, `<yrby-document>` fetches the URL with the
183
+ session cookie, and the action runs your authorization again. If the
184
+ response is `{ "grant": ... }`, the page resubscribes with the new grant and
185
+ keeps the document and any edits the server hasn't acknowledged. If the
186
+ refresh fails, the editor stops syncing until the page reloads, and
187
+ `<yrby-document>` dispatches `yrby:error`. A refresh fails when the response
188
+ is an error or has no grant, when it takes longer than 15 seconds, or when
189
+ the server rejects the new grant. The page doesn't renew grants on a timer,
190
+ so it won't interrupt a working subscription. Every reconnect after the
191
+ grant expires runs your permission check again, and that's the reason to
192
+ keep the lifetime short.
193
+
194
+ Without `refresh:`, an editor whose grant has expired stops syncing at its
195
+ next reconnect and starts again after the page reloads.
196
+
197
+ While a grant is rejected and not renewed, `<yrby-document>` makes the
198
+ editor inert, so nobody types into a document that can't sync. The editor
199
+ is also inert before its first sync and whenever the page can't reach the
200
+ server.
118
201
 
119
202
  ## Configuration
120
203
 
@@ -122,8 +205,8 @@ access check in `authorized?` (for example,
122
205
  LexxyRealtime.identity = ->(view) { { name: view.current_user.handle, color: nil } }
123
206
  ```
124
207
 
125
- By default, identity uses the first available `current_user` value from
126
- `name`, `username`, or `handle`, then falls back to `"Anonymous"`.
208
+ By default, the cursor name is the first of `current_user.name`,
209
+ `username`, or `handle` that has a value. Without one it's `"Anonymous"`.
127
210
 
128
211
  Full documentation, the demo app, and the JavaScript package:
129
212
  [repository README](https://github.com/jpcamara/lexxy-realtime#readme).