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 +4 -4
- data/README.md +126 -43
- data/app/assets/javascript/lexxy_realtime/lexxy-realtime.js +565 -12451
- data/app/assets/javascript/lexxy_realtime/lexxy-realtime.js.map +1 -1
- data/app/assets/javascript/lexxy_realtime/lexxy-realtime.min.js +10 -6
- data/app/assets/javascript/lexxy_realtime/yjs.js +11485 -0
- data/app/assets/javascript/lexxy_realtime/yjs.js.map +1 -0
- data/app/assets/javascript/lexxy_realtime/yjs.min.js +4 -0
- data/app/assets/javascript/lexxy_realtime/yrby-client.js +2262 -0
- data/app/assets/javascript/lexxy_realtime/yrby-client.js.map +1 -0
- data/app/assets/javascript/lexxy_realtime/yrby-client.min.js +1 -0
- data/app/assets/stylesheets/lexxy_realtime.css +11 -3
- data/app/channels/lexxy_realtime/document_channel.rb +53 -0
- data/lib/generators/lexxy_realtime/install/USAGE +7 -8
- data/lib/generators/lexxy_realtime/install/install_generator.rb +46 -37
- data/lib/lexxy-realtime.rb +1 -1
- data/lib/lexxy_realtime/collaborative.rb +58 -26
- data/lib/lexxy_realtime/engine.rb +2 -3
- data/lib/lexxy_realtime/form_builder.rb +37 -13
- data/lib/lexxy_realtime/version.rb +1 -1
- data/lib/lexxy_realtime.rb +28 -11
- metadata +26 -10
- data/lib/generators/lexxy_realtime/install/templates/application_cable_channel.rb +0 -6
- data/lib/generators/lexxy_realtime/install/templates/application_cable_connection.rb +0 -13
- data/lib/generators/lexxy_realtime/install/templates/document_channel.rb +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 992aeb83b159a49fae25e6e6897b120e288ad7f497352aa0580cc76d0d548d4e
|
|
4
|
+
data.tar.gz: aa54afbad36600e60da26c0db20bd619381bcf8ee499817ef5111dbfa32cfd91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
37
|
-
|
|
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 "
|
|
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
|
-
`
|
|
48
|
-
so
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
106
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
`
|
|
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,
|
|
126
|
-
`
|
|
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).
|