lexxy-realtime 0.7.1 → 0.9.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: 444a86a830537d630ccb1247fc6a601b84e30498c5e38d8ca5ce789e095117a6
4
+ data.tar.gz: 4373eae50d13855bf9895cc505ab323e2949bb73a90aa80d50888e9df4d14be2
5
5
  SHA512:
6
- metadata.gz: b94ee6e879b9aebe10bd52c2fb296afc6e29242d8929c7082434ae4d653d3b9fc5ad3a2e598ad871c9dd7d0e525d411c6f5c9e0d22f1280bde6718da4c360635
7
- data.tar.gz: 86deff4da480f6e97d13aa130d545e6625d246a05472792a3c9bcb07f2ee6fbbbc0f4b9a4a5c46b867666c6279d0f30ca906319c2ef20711799cd9df254d5545
6
+ metadata.gz: 23d8d6e07ccd5af9548f0bab391f4b91b1673fabc4a6e5f51d9ce9b4a7777f3abde513e01ebce95afab2b1c2c3b941fe9b9c3a140086f9f99648253f26c00d86
7
+ data.tar.gz: c782da01923dbd52a49016f4971d3f6309c3bd0074705b461c8c6cf028d6a7216a2316bc63dfb6b9e621f5b75f5cfaaf5a4e64a3e0c74e127cc03680164b70d7
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,114 @@ 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 `post.collaborative_document(:body).refresh`, inside the
114
+ channel's handler for each update. There's no background job, so
115
+ `post.body` is up to date as soon as the channel finishes handling the
116
+ update. An edit from Ruby, `post.collaborative_document(:body).edit`,
117
+ renders the body the same way.
107
118
 
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.
119
+ If rendering fails, the channel logs the error and keeps the update. The
120
+ next update renders the full document again. Until then, `post.body`
121
+ keeps its previous value.
111
122
 
112
123
  ## Access control
113
124
 
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)`).
125
+ The form helper renders a signed grant for the record and field.
126
+ `LexxyRealtime::DocumentChannel` extends yrby-rails' `Y::DocumentChannel`
127
+ and adds Action Text rendering. The grant uses its own purpose, so
128
+ `Y::DocumentChannel` rejects it. A client can't get around the block below
129
+ by subscribing to that channel. The channel rejects a missing, tampered,
130
+ expired, or wrong-field grant, a deleted record, and a field that isn't
131
+ declared with `has_collaborative_rich_text`. A valid grant means your app
132
+ rendered the form for this user.
133
+
134
+ To also check the user's current permissions when they subscribe, give
135
+ the channel a block:
136
+
137
+ ```ruby
138
+ # config/initializers/lexxy_realtime.rb
139
+ Rails.application.config.to_prepare do
140
+ LexxyRealtime::DocumentChannel.authorize_document do |record, name|
141
+ record.editable_by?(current_user, attribute: name)
142
+ end
143
+ end
144
+ ```
145
+
146
+ The block runs inside the channel, so you can use `current_user` and your
147
+ other connection identifiers. `editable_by?` is a placeholder for your own
148
+ permission check. If the block returns false or nil, the channel rejects
149
+ the subscription before it sends anything.
150
+
151
+ ## Grant lifetime and refresh
152
+
153
+ A grant lasts as long as GlobalID's signed-id default, which is one month
154
+ under Rails, and `expires_in:` on the form helper shortens it. The grant is
155
+ part of the rendered page, though, and Action Cable resubscribes with it
156
+ after every network drop. If the grant expires before the user is done
157
+ editing, the editor stops syncing at the next reconnect. To avoid that,
158
+ pair `expires_in:` with `refresh:`, a URL the page fetches when a
159
+ subscription is rejected:
160
+
161
+ ```erb
162
+ <%= form.collaborative_rich_textarea :body, expires_in: 10.minutes,
163
+ refresh: grant_post_path(@post) %>
164
+ ```
165
+
166
+ ```ruby
167
+ # config/routes.rb: resources :posts do get :grant, on: :member end
168
+ # app/controllers/posts_controller.rb
169
+ def grant
170
+ @post = current_user.posts.find(params[:id]) # your own authorization, again
171
+ render json: { grant: @post.collaborative_document(:body).grant(expires_in: 10.minutes) }
172
+ end
173
+ ```
174
+
175
+ For a field declared with `has_collaborative_rich_text`,
176
+ `collaborative_document(:body)` returns this gem's document. Its `grant` is
177
+ the one the form helper renders, its `channel` is
178
+ `LexxyRealtime::DocumentChannel`, `edit` changes the document from Ruby and
179
+ renders the body, and `refresh` renders the body on demand. Code that takes
180
+ a yrby-rails document, such as `collaborative_document_tag`, gets this
181
+ gem's grant and channel for the field.
182
+
183
+ That action grants write access, so its check must be at least as strict
184
+ as the page that renders the form. If it skips authorization, anyone who
185
+ can reach the URL gets a grant, and a short `expires_in:` protects nothing.
186
+
187
+ When a subscription is rejected, `<yrby-document>` fetches the URL with the
188
+ session cookie, and the action runs your authorization again. If the
189
+ response is `{ "grant": ... }`, the page resubscribes with the new grant and
190
+ keeps the document and any edits the server hasn't acknowledged. If the
191
+ refresh fails, the editor stops syncing until the page reloads, and
192
+ `<yrby-document>` dispatches `yrby:error`. A refresh fails when the response
193
+ is an error or has no grant, when it takes longer than 15 seconds, or when
194
+ the server rejects the new grant. The page doesn't renew grants on a timer,
195
+ so it won't interrupt a working subscription. Every reconnect after the
196
+ grant expires runs your permission check again, and that's the reason to
197
+ keep the lifetime short.
198
+
199
+ Without `refresh:`, an editor whose grant has expired stops syncing at its
200
+ next reconnect and starts again after the page reloads.
201
+
202
+ While a grant is rejected and not renewed, `<yrby-document>` makes the
203
+ editor inert, so nobody types into a document that can't sync. The editor
204
+ is also inert before its first sync and whenever the page can't reach the
205
+ server.
118
206
 
119
207
  ## Configuration
120
208
 
@@ -122,8 +210,8 @@ access check in `authorized?` (for example,
122
210
  LexxyRealtime.identity = ->(view) { { name: view.current_user.handle, color: nil } }
123
211
  ```
124
212
 
125
- By default, identity uses the first available `current_user` value from
126
- `name`, `username`, or `handle`, then falls back to `"Anonymous"`.
213
+ By default, the cursor name is the first of `current_user.name`,
214
+ `username`, or `handle` that has a value. Without one it's `"Anonymous"`.
127
215
 
128
216
  Full documentation, the demo app, and the JavaScript package:
129
217
  [repository README](https://github.com/jpcamara/lexxy-realtime#readme).