yrb-lite 0.1.0.beta1-aarch64-mingw-ucrt
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 +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +427 -0
- data/lib/yrb-lite.rb +4 -0
- data/lib/yrb_lite/3.4/yrb_lite.so +0 -0
- data/lib/yrb_lite/4.0/yrb_lite.so +0 -0
- data/lib/yrb_lite/sync.rb +456 -0
- data/lib/yrb_lite/version.rb +5 -0
- data/lib/yrb_lite.rb +20 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 85a4c9609572efc3a785e4958d34cc4924d7072b139aea9d24ed692be2901311
|
|
4
|
+
data.tar.gz: 36d14400c71c1929cc35354355e54c11b065d09c878746695b67c7d5a418d158
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2ae58d9ac7f3277904d12e1b331e38ab40a74d6bbe5945e99d1a55899a23bc92ef6ffa416901a446fb4de89f351a5d5d2a59d96aab8288b33e73dd781a3b7d62
|
|
7
|
+
data.tar.gz: 3db33542509c7a185b588c7e959e1a017e2b98956d7adbe207efeec6c8277388c28bb383363199cdde3c6c40158139052c0841548355705ddfb7dbdf9e593465
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
|
|
5
|
+
to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Thread-safe `YrbLite::Doc` and `YrbLite::Awareness` over `yrs` (magnus/rb-sys
|
|
12
|
+
native extension). The GVL is released during CRDT work so docs can run in
|
|
13
|
+
parallel on MRI.
|
|
14
|
+
- `YrbLite::Sync` ActionCable channel concern implementing the y-websocket
|
|
15
|
+
protocol (document sync plus awareness/presence). It's wire-compatible with
|
|
16
|
+
the [`@y-rb/actioncable`](https://www.npmjs.com/package/@y-rb/actioncable)
|
|
17
|
+
browser provider, and accepts its `{ update: ... }` envelope and `{ m: ... }`.
|
|
18
|
+
- A "record-before-distribute" mode via an `on_change` hook, so every change is
|
|
19
|
+
recorded durably before it's applied or relayed.
|
|
20
|
+
- Presence cleanup on disconnect, and idle-document eviction.
|
|
21
|
+
- Two backends: `sync_backend :memory` (default, classic ActionCable) and
|
|
22
|
+
`sync_backend :store` (stateless, AnyCable-ready, multi-process).
|
|
23
|
+
- Hardening against bad input: malformed or multi-message frames are dropped
|
|
24
|
+
before processing or relay, and native panics are contained at the FFI
|
|
25
|
+
boundary.
|
|
26
|
+
- Precompiled native gems for common platforms (no Rust toolchain needed to
|
|
27
|
+
install) via the cross-gem workflow.
|
|
28
|
+
|
|
29
|
+
[Unreleased]: https://github.com/jpcamara/yrb-lite/commits/main
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# yrb-lite
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jpcamara/yrb-lite/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Collaborative editing for Rails, backed by [y-crdt](https://github.com/y-crdt/y-crdt)
|
|
6
|
+
(the Rust library behind Y.js). Your Rails server speaks the y-websocket sync
|
|
7
|
+
protocol directly, so there's no separate Node process hosting the Y.js
|
|
8
|
+
documents.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
12
|
+
include YrbLite::Sync
|
|
13
|
+
|
|
14
|
+
def subscribed = sync_for(params[:id])
|
|
15
|
+
def receive(data) = sync_receive(data)
|
|
16
|
+
def unsubscribed = sync_clear_presence
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
On the browser, use the [`@y-rb/actioncable`](https://www.npmjs.com/package/@y-rb/actioncable)
|
|
21
|
+
provider as-is. Tiptap, ProseMirror, and BlockNote all sync through it.
|
|
22
|
+
|
|
23
|
+
## What you get
|
|
24
|
+
|
|
25
|
+
- Thread-safe `Doc` and `Awareness`. You can share them across Puma threads,
|
|
26
|
+
and the GVL is released while yrs does the actual work.
|
|
27
|
+
- The y-websocket protocol (document sync plus awareness/presence) as a
|
|
28
|
+
one-include ActionCable concern.
|
|
29
|
+
- A store-backed mode for AnyCable and multi-process deployments.
|
|
30
|
+
- An optional authoritative mode that records each change durably before it
|
|
31
|
+
goes out to anyone.
|
|
32
|
+
|
|
33
|
+
What it doesn't do: auth, read-only connections, rate limiting, webhooks,
|
|
34
|
+
metrics. Hocuspocus ships extensions for those; here you'd build them with
|
|
35
|
+
Rails.
|
|
36
|
+
|
|
37
|
+
## Testing
|
|
38
|
+
|
|
39
|
+
Ruby and Rust unit tests cover the core, and an end-to-end suite runs the real
|
|
40
|
+
stack: it fuzzes the protocol, throws garbage and chaos at the server, kills the
|
|
41
|
+
server mid-write to check crash recovery, and drives real browsers under load.
|
|
42
|
+
The benchmark numbers below are from a single laptop. Issues and PRs are
|
|
43
|
+
welcome.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
gem "yrb-lite"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Ruby 3.4 or newer. Precompiled gems ship for Linux and macOS on Ruby
|
|
52
|
+
3.4 and 4.0, so installing there needs no Rust. Other platforms (and any other
|
|
53
|
+
Ruby version) build from source, which needs [Rust](https://rustup.rs).
|
|
54
|
+
|
|
55
|
+
To work on the gem itself:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
git clone https://github.com/jpcamara/yrb-lite
|
|
59
|
+
cd yrb-lite
|
|
60
|
+
bundle install
|
|
61
|
+
bundle exec rake compile test
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The rest of the dev setup, plus the demo, is in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
65
|
+
|
|
66
|
+
## Docs
|
|
67
|
+
|
|
68
|
+
- The ActionCable concern and a quickstart are [below](#actioncable-integration).
|
|
69
|
+
- [`examples/actioncable-demo`](examples/actioncable-demo): a runnable Rails +
|
|
70
|
+
Tiptap app with collaborative cursors, the AnyCable setup, a Postgres store,
|
|
71
|
+
and the test/load suites.
|
|
72
|
+
- [CHANGELOG.md](CHANGELOG.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### Doc (Low-Level Document Sync)
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
require "yrb_lite"
|
|
80
|
+
|
|
81
|
+
# Create docs
|
|
82
|
+
doc = YrbLite::Doc.new # random client ID
|
|
83
|
+
doc = YrbLite::Doc.new(12345) # specific client ID
|
|
84
|
+
|
|
85
|
+
# Get document info
|
|
86
|
+
doc.client_id # => unique client identifier
|
|
87
|
+
doc.guid # => document GUID
|
|
88
|
+
|
|
89
|
+
# Encoding
|
|
90
|
+
doc.encode_state_vector # => current state vector
|
|
91
|
+
doc.encode_state_as_update # => full update
|
|
92
|
+
doc.encode_state_as_update(sv) # => update diff against state vector
|
|
93
|
+
|
|
94
|
+
# Applying updates
|
|
95
|
+
doc.apply_update(update_bytes) # apply raw V1 update
|
|
96
|
+
|
|
97
|
+
# Sync protocol messages
|
|
98
|
+
doc.sync_step1 # => SyncStep1 message (contains state vector)
|
|
99
|
+
doc.sync_step2(state_vector) # => SyncStep2 message (contains update)
|
|
100
|
+
doc.handle_sync_message(data) # => [msg_type, sync_type, response]
|
|
101
|
+
doc.encode_update_message(update) # => wrap update as sync Update message
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Awareness (Document + Presence)
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# Create awareness instances (each contains a Doc)
|
|
108
|
+
awareness = YrbLite::Awareness.new # random client ID
|
|
109
|
+
awareness = YrbLite::Awareness.new(12345) # specific client ID
|
|
110
|
+
|
|
111
|
+
# Get document info
|
|
112
|
+
awareness.client_id # => unique client identifier
|
|
113
|
+
awareness.guid # => document GUID
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Handling Sync Messages
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# When connection opens, send initial sync messages
|
|
120
|
+
initial_message = awareness.start
|
|
121
|
+
# Send initial_message to peer via WebSocket
|
|
122
|
+
|
|
123
|
+
# When receiving messages from peer
|
|
124
|
+
response = awareness.handle(incoming_data)
|
|
125
|
+
# Send response back to peer if not empty
|
|
126
|
+
send_to_peer(response) unless response.empty?
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### ActionCable Integration
|
|
130
|
+
|
|
131
|
+
`YrbLite::Sync` is a channel concern that implements the full y-websocket
|
|
132
|
+
protocol (document sync + awareness/presence) over ActionCable:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# app/channels/document_channel.rb
|
|
136
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
137
|
+
include YrbLite::Sync
|
|
138
|
+
|
|
139
|
+
# Optional persistence:
|
|
140
|
+
# on_load { |key| Document.find_by(key: key)&.content }
|
|
141
|
+
# on_save { |key, update| Document.find_by(key: key)&.update!(content: update) }
|
|
142
|
+
|
|
143
|
+
def subscribed
|
|
144
|
+
sync_for params[:id]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def receive(data)
|
|
148
|
+
sync_receive(data)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def unsubscribed
|
|
152
|
+
sync_clear_presence
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
One `YrbLite::Awareness` is shared per document key. Creating it is
|
|
158
|
+
mutex-serialized; after that everything runs lock-free on the thread-safe
|
|
159
|
+
native types. The concern answers SyncStep1 directly, relays document and
|
|
160
|
+
awareness changes to the other subscribers (not back to the sender), and calls
|
|
161
|
+
`on_save` after any message that changed the document.
|
|
162
|
+
|
|
163
|
+
`sync_unsubscribed` clears the connection's presence, so a closed tab doesn't
|
|
164
|
+
leave a stale cursor hanging until the client-side timeout. It also unloads the
|
|
165
|
+
document from memory once the last subscriber disconnects, which keeps the
|
|
166
|
+
process from holding onto every document it ever served. That unload only
|
|
167
|
+
happens when `on_load` is set and the document can be reloaded later; without
|
|
168
|
+
it, the in-memory copy is the only one and stays put.
|
|
169
|
+
|
|
170
|
+
Incoming frames are validated as a single well-formed protocol message before
|
|
171
|
+
anything processes or relays them. Malformed, truncated, multi-message,
|
|
172
|
+
oversized, or unknown frames are dropped. A bad frame can't crash the process: a
|
|
173
|
+
Rust panic is caught at the FFI boundary and re-raised as a Ruby exception. And
|
|
174
|
+
no single client can relay garbage that breaks the others in a room.
|
|
175
|
+
|
|
176
|
+
#### Multi-process deployments
|
|
177
|
+
|
|
178
|
+
Most Rails apps run several processes (Puma workers, multiple dynos), and any of
|
|
179
|
+
them might serve a given document. Two pieces keep them in step.
|
|
180
|
+
|
|
181
|
+
Broadcasts cross processes through the Action Cable adapter, so it needs to be a
|
|
182
|
+
real one (`redis` or `solid_cable`, not `async`). With that in place, a change
|
|
183
|
+
on one process reaches clients on all of them.
|
|
184
|
+
|
|
185
|
+
Each process also keeps its own copy of the document and applies broadcasts from
|
|
186
|
+
the others. The merge is an ordinary CRDT apply, idempotent and
|
|
187
|
+
order-independent, which keeps server reads and new-client handshakes current on
|
|
188
|
+
every process. Each broadcast carries a per-process id (`Sync.process_id`) that
|
|
189
|
+
tells a process to skip its own.
|
|
190
|
+
|
|
191
|
+
A cold process (no copy yet) rebuilds from the durable store through `on_load`.
|
|
192
|
+
In authoritative mode the store is always current, since changes are recorded
|
|
193
|
+
before they go out. Record-before-distribute therefore holds across processes:
|
|
194
|
+
whichever process receives a change records it to the shared store before
|
|
195
|
+
anyone, anywhere, sees it.
|
|
196
|
+
|
|
197
|
+
`bun multiprocess.mjs` in the demo runs clients across two processes and checks
|
|
198
|
+
the lot: convergence, fresh copies on both, presence across processes, and one
|
|
199
|
+
shared log.
|
|
200
|
+
|
|
201
|
+
##### AnyCable (`sync_backend :store`)
|
|
202
|
+
|
|
203
|
+
The default backend keeps that warm in-memory copy and relies on a `stream_from`
|
|
204
|
+
block running in Ruby for each broadcast. AnyCable breaks both assumptions.
|
|
205
|
+
anycable-go delivers broadcasts outside Ruby, so the block never runs. Each RPC
|
|
206
|
+
gets a fresh channel instance, which means ivars set in `subscribed` are gone by
|
|
207
|
+
`receive`. And there's no fixed worker-to-document mapping to lean on.
|
|
208
|
+
|
|
209
|
+
`sync_backend :store` is the path for that: stateless per message, no warm
|
|
210
|
+
copy.
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
214
|
+
include YrbLite::Sync
|
|
215
|
+
sync_backend :store
|
|
216
|
+
|
|
217
|
+
on_load { |key| MyStore.load(key) } # required: source of truth
|
|
218
|
+
on_change { |key, update| MyStore.append(key, update) } # required: record
|
|
219
|
+
|
|
220
|
+
def subscribed = sync_for(params[:id])
|
|
221
|
+
def receive(data) = sync_receive(data, params[:id]) # pass the key each call
|
|
222
|
+
def unsubscribed = sync_unsubscribed(params[:id])
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- `stream_from` is registered without a block; anycable-go does the relaying.
|
|
227
|
+
- A handshake (SyncStep1) is answered from the store. Changes are recorded, then
|
|
228
|
+
broadcast. Nothing is held in Ruby between calls, so any worker can handle any
|
|
229
|
+
message.
|
|
230
|
+
- Pass `params[:id]` into `sync_receive`/`sync_unsubscribed` so the document key
|
|
231
|
+
survives AnyCable's per-command instances.
|
|
232
|
+
- The sender gets its own updates echoed back (no Ruby callback to filter them).
|
|
233
|
+
That's a no-op, since applying an update twice does nothing.
|
|
234
|
+
|
|
235
|
+
The demo checks this against a real anycable-go + RPC server
|
|
236
|
+
(`frontend/anycable_probe.mjs`, `anycable_concurrent.mjs`): liveness, the
|
|
237
|
+
`@y-rb/actioncable` provider, cross-process reads, and concurrent convergence.
|
|
238
|
+
|
|
239
|
+
The wire format is the standard y-protocols binary messages, base64-encoded in
|
|
240
|
+
the ActionCable envelope. The server accepts the `@y-rb/actioncable` provider's
|
|
241
|
+
`{ "update" => ... }` envelope (and its own `{ "m" => ... }`) and sends one
|
|
242
|
+
message per frame, so the off-the-shelf provider works with no custom client
|
|
243
|
+
code:
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
import { createConsumer } from "@rails/actioncable"
|
|
247
|
+
import { WebsocketProvider } from "@y-rb/actioncable"
|
|
248
|
+
|
|
249
|
+
const provider = new WebsocketProvider(ydoc, createConsumer(), "DocumentChannel", { id: docId })
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
[`examples/actioncable-demo`](examples/actioncable-demo) is a full Rails + Tiptap
|
|
253
|
+
app using that provider, with end-to-end tests.
|
|
254
|
+
|
|
255
|
+
#### Authoritative audit mode (record before distribute)
|
|
256
|
+
|
|
257
|
+
By default a change is applied and broadcast immediately (the fast path). If you
|
|
258
|
+
need to durably record every change before anyone else sees it, whether for
|
|
259
|
+
auditing or to guarantee nothing is distributed until it's stored, register an
|
|
260
|
+
`on_change` recorder:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
264
|
+
include YrbLite::Sync
|
|
265
|
+
|
|
266
|
+
on_change do |key, update|
|
|
267
|
+
# Synchronous, durable write. `update` is the exact CRDT delta.
|
|
268
|
+
AuditLog.append!(key, update) # raise to REJECT the change
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def subscribed = sync_for(params[:id])
|
|
272
|
+
def receive(data) = sync_receive(data)
|
|
273
|
+
def unsubscribed = sync_clear_presence
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
With `on_change` registered, a change is recorded before it goes anywhere. The
|
|
278
|
+
recorder writes the raw CRDT delta synchronously; only then is the change
|
|
279
|
+
applied to the shared document and broadcast. The whole sequence runs under a
|
|
280
|
+
per-document lock, so every change to a document is recorded in the same order
|
|
281
|
+
it's applied. That's what makes the log authoritative. Replay the deltas onto a
|
|
282
|
+
fresh `Y.Doc` and you get the document back exactly.
|
|
283
|
+
|
|
284
|
+
If the recorder raises (say the store is down), the change is rejected: not
|
|
285
|
+
applied, not sent to anyone. The cost is a synchronous durable write per change,
|
|
286
|
+
which serializes that document's writes. Other documents use other locks and run
|
|
287
|
+
in parallel.
|
|
288
|
+
|
|
289
|
+
`on_change` and `on_save` are separate. `on_save` snapshots the whole document
|
|
290
|
+
when it gets a chance; `on_change` is the per-change log. The demo's `AUDIT=1`
|
|
291
|
+
mode (in [`examples/actioncable-demo`](examples/actioncable-demo)) wires
|
|
292
|
+
`on_change` to an fsync'd append-only log and checks, end to end, that the log
|
|
293
|
+
alone rebuilds the document.
|
|
294
|
+
|
|
295
|
+
### User Awareness/Presence
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# Set local user state (cursor position, name, etc.)
|
|
299
|
+
awareness.set_local_state('{"user": {"name": "Alice", "color": "#ff0000"}}')
|
|
300
|
+
|
|
301
|
+
# Get local state
|
|
302
|
+
awareness.local_state # => '{"user": {"name": "Alice", "color": "#ff0000"}}'
|
|
303
|
+
|
|
304
|
+
# Clear local state (e.g., when disconnecting)
|
|
305
|
+
awareness.clear_local_state
|
|
306
|
+
|
|
307
|
+
# Encode awareness update for broadcasting
|
|
308
|
+
update = awareness.encode_awareness_update
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Low-Level Access
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
# Get state vector for manual sync
|
|
315
|
+
sv = awareness.encode_state_vector
|
|
316
|
+
|
|
317
|
+
# Get update diffed against a state vector
|
|
318
|
+
update = awareness.encode_state_as_update(remote_state_vector)
|
|
319
|
+
|
|
320
|
+
# Apply raw update to the document
|
|
321
|
+
awareness.apply_update(update_bytes)
|
|
322
|
+
|
|
323
|
+
# Wrap raw update data in a sync message
|
|
324
|
+
message = awareness.encode_update(update_bytes)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Thread Safety
|
|
328
|
+
|
|
329
|
+
Unlike the official `y-rb` gem, yrb-lite is safe to share across Ruby threads. A
|
|
330
|
+
`Doc` or `Awareness` can be used concurrently from Puma workers, ActionCable
|
|
331
|
+
connection threads, or background jobs without external locking.
|
|
332
|
+
|
|
333
|
+
That comes from how the underlying types work, not from locking on top:
|
|
334
|
+
|
|
335
|
+
- `yrs::Doc` is `Send + Sync`. Every operation takes the document's internal
|
|
336
|
+
RwLock with blocking semantics (`read_blocking`/`write_blocking`), so
|
|
337
|
+
concurrent access serializes instead of erroring or corrupting state.
|
|
338
|
+
- `yrs::sync::Awareness` is built for multi-threaded servers: client states
|
|
339
|
+
live in a `DashMap` and the whole API is `&self`.
|
|
340
|
+
- The extension adds no interior-mutability tricks. There's no `RefCell`, where
|
|
341
|
+
a re-entrant borrow would panic and take the Ruby process down with it.
|
|
342
|
+
Each native method opens and closes its transaction in one call, so no lock
|
|
343
|
+
or borrow outlives a call and there's nothing to deadlock on.
|
|
344
|
+
- A `Send + Sync` static assertion for both wrapped types lives in `lib.rs`. If
|
|
345
|
+
a yrs upgrade regressed this, the gem would fail to compile instead of quietly
|
|
346
|
+
turning thread-unsafe.
|
|
347
|
+
|
|
348
|
+
`test/thread_safety_test.rb` runs shared docs, the full sync handshake, fan-in
|
|
349
|
+
sync, and awareness state across 8 threads at once, and checks the interleaving
|
|
350
|
+
doesn't change convergence.
|
|
351
|
+
|
|
352
|
+
### Parallelism (GVL release)
|
|
353
|
+
|
|
354
|
+
Every method that does real CRDT work (applying updates, encoding state,
|
|
355
|
+
handling sync messages) releases Ruby's Global VM Lock
|
|
356
|
+
(`rb_thread_call_without_gvl`) while the native code runs. That buys two things.
|
|
357
|
+
|
|
358
|
+
CRDT work runs in parallel across Ruby threads on MRI, not just
|
|
359
|
+
JRuby/TruffleRuby. `bench/parallelism_bench.rb` measures over 2x wall-clock
|
|
360
|
+
speedup applying a ~900 KB update concurrently; native code that held the GVL
|
|
361
|
+
couldn't beat serial time.
|
|
362
|
+
|
|
363
|
+
A slow operation also can't stall the VM. A thread applying a large update holds
|
|
364
|
+
the doc's write lock without holding the GVL, so other Ruby threads keep running
|
|
365
|
+
instead of queuing behind it.
|
|
366
|
+
|
|
367
|
+
Each method has the same shape: copy the Ruby byte string, drop the GVL, do the
|
|
368
|
+
yrs work (taking and releasing the doc lock entirely inside the closure), take
|
|
369
|
+
the GVL back, then build Ruby objects. No Ruby API is touched without the GVL,
|
|
370
|
+
and the doc lock is never held across a GVL boundary, so the lock order can't
|
|
371
|
+
deadlock. Panics in native code are caught and re-raised as Ruby exceptions.
|
|
372
|
+
|
|
373
|
+
## Message Type Constants
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
YrbLite::MSG_SYNC # 0 - Document sync messages
|
|
377
|
+
YrbLite::MSG_AWARENESS # 1 - User presence data
|
|
378
|
+
YrbLite::MSG_AUTH # 2 - Authentication
|
|
379
|
+
YrbLite::MSG_QUERY_AWARENESS # 3 - Request awareness state
|
|
380
|
+
|
|
381
|
+
YrbLite::MSG_SYNC_STEP1 # 0 - State vector request
|
|
382
|
+
YrbLite::MSG_SYNC_STEP2 # 1 - Update response
|
|
383
|
+
YrbLite::MSG_SYNC_UPDATE # 2 - Incremental update
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Sync Flow
|
|
387
|
+
|
|
388
|
+
```
|
|
389
|
+
Client A Server
|
|
390
|
+
| |
|
|
391
|
+
|-------- start() --------------->|
|
|
392
|
+
| (SyncStep1 + Awareness) |
|
|
393
|
+
| |
|
|
394
|
+
|<------- handle() response ------|
|
|
395
|
+
| (SyncStep2) |
|
|
396
|
+
| |
|
|
397
|
+
| (Document synchronized!) |
|
|
398
|
+
| |
|
|
399
|
+
|<------- updates ----------------|
|
|
400
|
+
|-------- updates --------------->|
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Development
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
# Setup
|
|
407
|
+
bundle install
|
|
408
|
+
|
|
409
|
+
# Build extension
|
|
410
|
+
rake compile
|
|
411
|
+
|
|
412
|
+
# Run tests
|
|
413
|
+
rake test
|
|
414
|
+
|
|
415
|
+
# Clean build artifacts
|
|
416
|
+
rake clean
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## License
|
|
420
|
+
|
|
421
|
+
MIT License
|
|
422
|
+
|
|
423
|
+
## Acknowledgments
|
|
424
|
+
|
|
425
|
+
- [y-crdt/yrs](https://github.com/y-crdt/y-crdt) - The Rust implementation of Y.js
|
|
426
|
+
- [Magnus](https://github.com/matsadler/magnus) - Ruby bindings for Rust
|
|
427
|
+
- [rb-sys](https://github.com/oxidize-rb/rb-sys) - Rust extensions for Ruby
|
data/lib/yrb-lite.rb
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module YrbLite
|
|
7
|
+
# y-websocket protocol over ActionCable.
|
|
8
|
+
#
|
|
9
|
+
# Include this module in an ActionCable channel to sync Y.js documents
|
|
10
|
+
# (and awareness/presence) with browser clients. Messages are the standard
|
|
11
|
+
# y-protocols binary messages, base64-encoded in a JSON envelope:
|
|
12
|
+
#
|
|
13
|
+
# { "m" => "<base64 bytes>" } # client -> server
|
|
14
|
+
# { "m" => "...", "origin" => "<id>" } # server -> subscribers
|
|
15
|
+
#
|
|
16
|
+
# Example:
|
|
17
|
+
# class DocumentChannel < ApplicationCable::Channel
|
|
18
|
+
# include YrbLite::Sync
|
|
19
|
+
#
|
|
20
|
+
# on_load { |key| Document.find_by(key: key)&.content }
|
|
21
|
+
# on_save { |key, update| Document.find_by(key: key)&.update!(content: update) }
|
|
22
|
+
#
|
|
23
|
+
# def subscribed
|
|
24
|
+
# sync_for params[:id]
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def receive(data)
|
|
28
|
+
# sync_receive(data)
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# def unsubscribed
|
|
32
|
+
# sync_clear_presence
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# The shared YrbLite::Awareness instances are safe to use from ActionCable's
|
|
37
|
+
# worker thread pool: the native types are Send + Sync and every operation
|
|
38
|
+
# releases the GVL, so concurrent clients sync in parallel.
|
|
39
|
+
module Sync
|
|
40
|
+
# Validated frame kinds from Awareness#message_kind. A frame only gets a
|
|
41
|
+
# non-DROP kind if it is exactly one well-formed message; anything
|
|
42
|
+
# malformed, truncated, multi-message, or unknown is dropped before it can
|
|
43
|
+
# be processed or relayed.
|
|
44
|
+
MSG_KIND_DROP = 0
|
|
45
|
+
MSG_KIND_SYNC_STEP1 = 1
|
|
46
|
+
MSG_KIND_UPDATE = 2
|
|
47
|
+
MSG_KIND_AWARENESS = 3
|
|
48
|
+
MSG_KIND_AWARENESS_QUERY = 4
|
|
49
|
+
|
|
50
|
+
def self.included(base)
|
|
51
|
+
base.extend(ClassMethods)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module ClassMethods
|
|
55
|
+
# Load persisted document state. Called once per key with (key);
|
|
56
|
+
# return a binary Y.js update (or nil for a fresh document).
|
|
57
|
+
def on_load(callable = nil, &block)
|
|
58
|
+
@on_load = callable || block if callable || block
|
|
59
|
+
@on_load
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Persist document state. Called with (key, update) after every
|
|
63
|
+
# message that modified the document.
|
|
64
|
+
def on_save(callable = nil, &block)
|
|
65
|
+
@on_save = callable || block if callable || block
|
|
66
|
+
@on_save
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Record every document change durably before it is applied or
|
|
70
|
+
# distributed (authoritative audit mode). Called synchronously with
|
|
71
|
+
# (key, update), where update is the exact CRDT delta, serialized per
|
|
72
|
+
# document so the recorded order is the apply order. If the block raises,
|
|
73
|
+
# the change is rejected: neither applied to the shared document nor
|
|
74
|
+
# broadcast to other subscribers.
|
|
75
|
+
#
|
|
76
|
+
# Registering an on_change switches that channel onto the strict path
|
|
77
|
+
# (record, apply, broadcast). Without it, the default fast path applies
|
|
78
|
+
# and broadcasts, with an optional on_save snapshot.
|
|
79
|
+
def on_change(callable = nil, &block)
|
|
80
|
+
@on_change = callable || block if callable || block
|
|
81
|
+
@on_change
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Select the document backend:
|
|
85
|
+
# :memory (default): keep a warm in-memory replica per process and keep
|
|
86
|
+
# it current via a custom stream_from callback. Fast, but it assumes
|
|
87
|
+
# classic ActionCable (the callback runs in Ruby) and
|
|
88
|
+
# process<->document affinity.
|
|
89
|
+
# :store: stateless per message, with no warm replica and no custom
|
|
90
|
+
# stream callback. Handshakes and reads are served from the durable
|
|
91
|
+
# store (`on_load`); changes are recorded (`on_change`) and relayed.
|
|
92
|
+
# Works under AnyCable (broadcasts handled outside Ruby, no worker
|
|
93
|
+
# affinity) and across processes. Requires `on_load` and `on_change`.
|
|
94
|
+
def sync_backend(mode = nil)
|
|
95
|
+
@sync_backend = mode if mode
|
|
96
|
+
@sync_backend || :memory
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Call from `subscribed`. Streams broadcasts for this document and
|
|
101
|
+
# transmits the server's opening handshake (SyncStep1 + awareness).
|
|
102
|
+
def sync_for(key)
|
|
103
|
+
@sync_key = key.to_s
|
|
104
|
+
@sync_origin = SecureRandom.hex(8)
|
|
105
|
+
@sync_clients = [] # awareness client IDs seen on this connection
|
|
106
|
+
|
|
107
|
+
return sync_for_store_backed if self.class.sync_backend == :store
|
|
108
|
+
|
|
109
|
+
Sync.subscribe(@sync_key)
|
|
110
|
+
awareness = sync_awareness
|
|
111
|
+
|
|
112
|
+
stream_from sync_stream_name, coder: ActiveSupport::JSON do |payload|
|
|
113
|
+
sync_on_broadcast(payload)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Opening handshake: SyncStep1 then the current awareness, each as its
|
|
117
|
+
# own single-message frame, so providers that parse one message per frame
|
|
118
|
+
# (e.g. @y-rb/actioncable) handle both. The client replies SyncStep2 to
|
|
119
|
+
# the SyncStep1, delivering its state to the server.
|
|
120
|
+
sync_transmit(awareness.sync_step1)
|
|
121
|
+
sync_transmit(awareness.encode_awareness_update)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Call from `receive`. Applies the client's message, replies directly
|
|
125
|
+
# when the protocol calls for it, and relays document/awareness changes
|
|
126
|
+
# to the other subscribers.
|
|
127
|
+
#
|
|
128
|
+
# If an `on_change` recorder is registered, document changes take the
|
|
129
|
+
# strict authoritative path (record -> apply -> broadcast, serialized per
|
|
130
|
+
# document); otherwise the fast path is used.
|
|
131
|
+
def sync_receive(data, key = nil)
|
|
132
|
+
# Pass `key` (params[:id]) when your transport doesn't keep the channel
|
|
133
|
+
# instance alive across actions. Under AnyCable each RPC command gets a
|
|
134
|
+
# fresh channel, so instance variables set in `subscribed` are gone here.
|
|
135
|
+
@sync_key = key.to_s if key
|
|
136
|
+
|
|
137
|
+
# Accept both envelope keys: "m" (yrb-lite's own clients) and "update"
|
|
138
|
+
# (the @y-rb/actioncable browser provider).
|
|
139
|
+
m = data.is_a?(Hash) ? (data["m"] || data["update"]) : nil
|
|
140
|
+
return unless m.is_a?(String)
|
|
141
|
+
|
|
142
|
+
begin
|
|
143
|
+
bytes = Base64.strict_decode64(m)
|
|
144
|
+
rescue ArgumentError
|
|
145
|
+
return # not valid base64; ignore the frame and keep the connection
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
return sync_receive_store_backed(m, bytes) if self.class.sync_backend == :store
|
|
149
|
+
|
|
150
|
+
awareness = sync_awareness
|
|
151
|
+
kind = awareness.message_kind(bytes)
|
|
152
|
+
# Malformed / truncated / multi-message / unknown frames are dropped
|
|
153
|
+
# before they can be processed or relayed to other clients.
|
|
154
|
+
return if kind == MSG_KIND_DROP
|
|
155
|
+
|
|
156
|
+
sync_track_clients(awareness, bytes) if kind == MSG_KIND_AWARENESS
|
|
157
|
+
|
|
158
|
+
if kind == MSG_KIND_UPDATE && self.class.on_change
|
|
159
|
+
sync_apply_authoritative(awareness, m, bytes)
|
|
160
|
+
else
|
|
161
|
+
sync_apply_fast(awareness, m, bytes, kind)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Call from `unsubscribed`. Clears the presence states this connection
|
|
166
|
+
# introduced and tells the other subscribers to drop those cursors, so a
|
|
167
|
+
# closed tab or dropped socket doesn't leave a ghost cursor behind until
|
|
168
|
+
# the client-side timeout reaps it.
|
|
169
|
+
def sync_clear_presence
|
|
170
|
+
return if @sync_clients.nil? || @sync_clients.empty?
|
|
171
|
+
|
|
172
|
+
removal = sync_awareness.remove_clients(@sync_clients)
|
|
173
|
+
@sync_clients = []
|
|
174
|
+
return if removal.empty?
|
|
175
|
+
|
|
176
|
+
sync_distribute(Base64.strict_encode64(removal))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Call from `unsubscribed`. Clears this connection's presence and, when the
|
|
180
|
+
# last subscriber for the document leaves, persists and unloads it from
|
|
181
|
+
# memory (only when an `on_load` is configured to bring it back; otherwise
|
|
182
|
+
# the in-memory document is the only copy and is kept). Prevents a
|
|
183
|
+
# long-running server from accumulating every document it has ever served.
|
|
184
|
+
def sync_unsubscribed(key = nil)
|
|
185
|
+
@sync_key = key.to_s if key
|
|
186
|
+
return if self.class.sync_backend == :store # nothing cached per process
|
|
187
|
+
|
|
188
|
+
sync_clear_presence
|
|
189
|
+
saver = self.class.on_save
|
|
190
|
+
Sync.release(@sync_key, evictable: !self.class.on_load.nil?) do |awareness|
|
|
191
|
+
saver&.call(@sync_key, awareness.encode_state_as_update)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The shared Awareness (document + presence) for this channel's key.
|
|
196
|
+
# Also useful for server-side reads, e.g.:
|
|
197
|
+
# sync_awareness.encode_state_as_update
|
|
198
|
+
def sync_awareness
|
|
199
|
+
Sync.awareness_for(@sync_key, self.class.on_load)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
# Default path: apply the message, answer direct requests, relay
|
|
205
|
+
# state-changing messages to the other subscribers. Routing comes from the
|
|
206
|
+
# native `kind` (from Awareness#message_kind) rather than peeking at bytes.
|
|
207
|
+
# Document changes (SyncStep2, Update) and awareness get relayed; requests
|
|
208
|
+
# (SyncStep1, awareness-query) are answered above and not relayed. An
|
|
209
|
+
# optional on_save snapshot is taken after a document change.
|
|
210
|
+
def sync_apply_fast(awareness, encoded, bytes, kind)
|
|
211
|
+
response = awareness.handle(bytes)
|
|
212
|
+
sync_transmit(response) unless response.empty?
|
|
213
|
+
|
|
214
|
+
return unless [MSG_KIND_UPDATE, MSG_KIND_AWARENESS].include?(kind)
|
|
215
|
+
|
|
216
|
+
sync_distribute(encoded)
|
|
217
|
+
sync_persist if kind == MSG_KIND_UPDATE
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Authoritative path: record the change durably, then apply it to the
|
|
221
|
+
# shared document, then distribute it. The sequence runs under a
|
|
222
|
+
# per-document lock so changes are recorded in a single total order that
|
|
223
|
+
# matches the order they're applied, and nothing is distributed (or applied)
|
|
224
|
+
# before it has been recorded. If the recorder raises, the change is
|
|
225
|
+
# rejected (not applied, not broadcast) and the exception propagates, so the
|
|
226
|
+
# channel can surface it and the client can resync.
|
|
227
|
+
def sync_apply_authoritative(awareness, encoded, bytes)
|
|
228
|
+
recorder = self.class.on_change
|
|
229
|
+
|
|
230
|
+
modified = Sync.lock_for(@sync_key).synchronize do
|
|
231
|
+
update = awareness.update_from_message(bytes)
|
|
232
|
+
# A no-op message (e.g. the empty SyncStep2 in a client's opening
|
|
233
|
+
# handshake) carries no change, so there's nothing to record or relay.
|
|
234
|
+
next false unless update
|
|
235
|
+
|
|
236
|
+
recorder.call(@sync_key, update) # durable write; raise to reject
|
|
237
|
+
awareness.apply_update(update) # only recorded changes reach the doc
|
|
238
|
+
sync_distribute(encoded) # ...and only then the wire
|
|
239
|
+
true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
sync_persist if modified
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Single broadcast point for both paths (and presence removal), so the
|
|
246
|
+
# relay semantics live in one place and tests can observe distribution.
|
|
247
|
+
# `origin` identifies the sending connection (don't echo to it); `pid`
|
|
248
|
+
# identifies the sending process (other processes apply it to their own
|
|
249
|
+
# replica; see sync_on_broadcast).
|
|
250
|
+
def sync_distribute(encoded)
|
|
251
|
+
ActionCable.server.broadcast(
|
|
252
|
+
sync_stream_name,
|
|
253
|
+
sync_envelope(encoded, "origin" => @sync_origin, "pid" => Sync.process_id)
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Transmit raw protocol bytes to this connection (base64, dual-key).
|
|
258
|
+
def sync_transmit(bytes)
|
|
259
|
+
transmit(sync_envelope(Base64.strict_encode64(bytes)))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Build an outgoing envelope. We send the payload under both keys: "m"
|
|
263
|
+
# (yrb-lite's own clients) and "update" (the @y-rb/actioncable provider),
|
|
264
|
+
# so either client works against the same server.
|
|
265
|
+
def sync_envelope(encoded, extra = {})
|
|
266
|
+
{ "m" => encoded, "update" => encoded }.merge(extra)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Handle a broadcast delivered by the cable adapter. With a multi-process
|
|
270
|
+
# adapter (Redis, solid_cable), it may have come from another server
|
|
271
|
+
# process. Keep this process's in-memory replica current with changes that
|
|
272
|
+
# originated elsewhere, then relay to this connection's browser.
|
|
273
|
+
def sync_on_broadcast(payload)
|
|
274
|
+
sync_apply_remote(payload["m"]) if payload["pid"] != Sync.process_id
|
|
275
|
+
transmit(payload) unless payload["origin"] == @sync_origin
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Apply a change that originated on another process to this process's
|
|
279
|
+
# replica, without re-recording it (the origin process already recorded it
|
|
280
|
+
# before broadcasting). The CRDT merge is idempotent and commutative, so a
|
|
281
|
+
# cold replica converges regardless of ordering, and applying from several
|
|
282
|
+
# local connections is harmless.
|
|
283
|
+
def sync_apply_remote(encoded)
|
|
284
|
+
return unless encoded.is_a?(String)
|
|
285
|
+
|
|
286
|
+
begin
|
|
287
|
+
bytes = Base64.strict_decode64(encoded)
|
|
288
|
+
rescue ArgumentError
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
awareness = sync_awareness
|
|
293
|
+
case awareness.message_kind(bytes)
|
|
294
|
+
when MSG_KIND_UPDATE
|
|
295
|
+
update = awareness.update_from_message(bytes)
|
|
296
|
+
awareness.apply_update(update) if update
|
|
297
|
+
when MSG_KIND_AWARENESS
|
|
298
|
+
awareness.handle(bytes)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# -- Store-backed (AnyCable-native) path --------------------------------
|
|
303
|
+
|
|
304
|
+
# Subscribe without a custom block, so AnyCable (which delivers broadcasts
|
|
305
|
+
# outside Ruby) relays them directly. Send the opening SyncStep1 built from
|
|
306
|
+
# the durable store. No warm replica is kept.
|
|
307
|
+
def sync_for_store_backed
|
|
308
|
+
stream_from sync_stream_name
|
|
309
|
+
sync_transmit(sync_load_doc.sync_step1)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Stateless per message: no warm replica, no assumptions about which process
|
|
313
|
+
# owns a document. A client's SyncStep1 is answered from the store, document
|
|
314
|
+
# changes are recorded durably before relay and then broadcast, and
|
|
315
|
+
# awareness is relayed best-effort. Echoing back to the sender is harmless,
|
|
316
|
+
# since the CRDT apply is idempotent.
|
|
317
|
+
def sync_receive_store_backed(encoded, bytes)
|
|
318
|
+
case Sync.codec.message_kind(bytes)
|
|
319
|
+
when MSG_KIND_SYNC_STEP1
|
|
320
|
+
result = sync_load_doc.handle_sync_message(bytes)
|
|
321
|
+
sync_transmit(result[2]) if result
|
|
322
|
+
when MSG_KIND_UPDATE
|
|
323
|
+
update = Sync.codec.update_from_message(bytes)
|
|
324
|
+
return unless update
|
|
325
|
+
|
|
326
|
+
self.class.on_change&.call(@sync_key, update) # record before relay
|
|
327
|
+
sync_distribute(encoded)
|
|
328
|
+
when MSG_KIND_AWARENESS
|
|
329
|
+
sync_distribute(encoded)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Build a fresh document from the durable store (on_load).
|
|
334
|
+
def sync_load_doc
|
|
335
|
+
doc = YrbLite::Doc.new
|
|
336
|
+
state = self.class.on_load&.call(@sync_key)
|
|
337
|
+
doc.apply_update(state) if state
|
|
338
|
+
doc
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Record the awareness client IDs carried by an incoming message (already
|
|
342
|
+
# known to be an awareness frame) so we can clear them when this connection
|
|
343
|
+
# closes.
|
|
344
|
+
def sync_track_clients(awareness, bytes)
|
|
345
|
+
awareness.awareness_client_ids(bytes).each do |id|
|
|
346
|
+
@sync_clients << id unless @sync_clients.include?(id)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def sync_stream_name
|
|
351
|
+
"yrb_lite:#{@sync_key}"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def sync_persist
|
|
355
|
+
return unless (saver = self.class.on_save)
|
|
356
|
+
|
|
357
|
+
saver.call(@sync_key, sync_awareness.encode_state_as_update)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# -- Shared document registry ------------------------------------------
|
|
361
|
+
|
|
362
|
+
@registry = {}
|
|
363
|
+
@locks = {}
|
|
364
|
+
@subscribers = Hash.new(0)
|
|
365
|
+
@registry_mutex = Mutex.new
|
|
366
|
+
|
|
367
|
+
class << self
|
|
368
|
+
# A stable id for this server process, stamped on every broadcast so
|
|
369
|
+
# other processes know to apply it to their replica and this process
|
|
370
|
+
# knows to skip its own. Survives for the life of the process.
|
|
371
|
+
def process_id
|
|
372
|
+
@process_id ||= SecureRandom.hex(8)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# A shared, stateless decoder for the store-backed path. message_kind and
|
|
376
|
+
# update_from_message only read their argument (they don't touch the
|
|
377
|
+
# instance's document), so one shared instance is safe across threads.
|
|
378
|
+
def codec
|
|
379
|
+
@codec ||= YrbLite::Awareness.new
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Get or create the shared Awareness for a key. Creation (including
|
|
383
|
+
# the on_load callback) is serialized under a mutex so concurrent
|
|
384
|
+
# subscribers can never observe two documents for one key; all
|
|
385
|
+
# subsequent operations run lock-free on the thread-safe native types.
|
|
386
|
+
def awareness_for(key, loader = nil)
|
|
387
|
+
@registry_mutex.synchronize do
|
|
388
|
+
@registry[key] ||= begin
|
|
389
|
+
awareness = YrbLite::Awareness.new
|
|
390
|
+
if loader && (state = loader.call(key))
|
|
391
|
+
awareness.apply_update(state)
|
|
392
|
+
end
|
|
393
|
+
awareness
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Per-document mutex serializing the authoritative record -> apply ->
|
|
399
|
+
# broadcast section, so a document's audit log is a single total order.
|
|
400
|
+
# Only briefly holds the registry mutex to fetch/create the lock; the
|
|
401
|
+
# durable write itself runs while holding only this per-key lock.
|
|
402
|
+
def lock_for(key)
|
|
403
|
+
@registry_mutex.synchronize { @locks[key] ||= Mutex.new }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Count a new subscriber for a document.
|
|
407
|
+
def subscribe(key)
|
|
408
|
+
@registry_mutex.synchronize { @subscribers[key] += 1 }
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Drop a subscriber. When the last one leaves and the document is
|
|
412
|
+
# evictable (there's an on_load to bring it back, so unloading can't lose
|
|
413
|
+
# data), persist it via the given block and unload it from memory, so a
|
|
414
|
+
# long-running server doesn't accumulate every document and lock it has
|
|
415
|
+
# ever seen. Returns true if the document was evicted.
|
|
416
|
+
#
|
|
417
|
+
# The persist runs outside the registry lock (it may do I/O), and we
|
|
418
|
+
# re-check the subscriber count afterward: if someone reconnected while
|
|
419
|
+
# we were saving, eviction is aborted and the warm document is kept.
|
|
420
|
+
def release(key, evictable:)
|
|
421
|
+
awareness = @registry_mutex.synchronize do
|
|
422
|
+
@subscribers[key] -= 1 if @subscribers[key].positive?
|
|
423
|
+
next nil unless @subscribers[key].zero?
|
|
424
|
+
|
|
425
|
+
@subscribers.delete(key)
|
|
426
|
+
evictable ? @registry[key] : nil
|
|
427
|
+
end
|
|
428
|
+
return false unless awareness
|
|
429
|
+
|
|
430
|
+
yield awareness if block_given?
|
|
431
|
+
|
|
432
|
+
@registry_mutex.synchronize do
|
|
433
|
+
# A subscriber may have returned during the persist above.
|
|
434
|
+
next false unless @subscribers[key].zero?
|
|
435
|
+
|
|
436
|
+
@subscribers.delete(key)
|
|
437
|
+
@locks.delete(key)
|
|
438
|
+
!@registry.delete(key).nil?
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def registry
|
|
443
|
+
@registry_mutex.synchronize { @registry.dup }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Clear all documents (useful for testing).
|
|
447
|
+
def reset!
|
|
448
|
+
@registry_mutex.synchronize do
|
|
449
|
+
@registry = {}
|
|
450
|
+
@locks = {}
|
|
451
|
+
@subscribers = Hash.new(0)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
data/lib/yrb_lite.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "yrb_lite/version"
|
|
4
|
+
|
|
5
|
+
# Load the native extension. Precompiled gems ship it in a per-Ruby-version
|
|
6
|
+
# subdir (lib/yrb_lite/<major.minor>/yrb_lite.<ext>); a source build puts it
|
|
7
|
+
# flat at lib/yrb_lite/yrb_lite.<ext>. Try the versioned path first, fall back.
|
|
8
|
+
begin
|
|
9
|
+
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
10
|
+
require_relative "yrb_lite/#{Regexp.last_match(1)}/yrb_lite"
|
|
11
|
+
rescue LoadError
|
|
12
|
+
require_relative "yrb_lite/yrb_lite"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module YrbLite
|
|
16
|
+
# Error class is defined in Rust extension
|
|
17
|
+
|
|
18
|
+
# Autoload Sync module - only loaded when ActionCable is available
|
|
19
|
+
autoload :Sync, "yrb_lite/sync"
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yrb-lite
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.beta1
|
|
5
|
+
platform: aarch64-mingw-ucrt
|
|
6
|
+
authors:
|
|
7
|
+
- JP Camara
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: base64
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake-compiler
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.2'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.2'
|
|
69
|
+
description: yrb-lite is a thread-safe Ruby binding over the Rust y-crdt (yrs) library
|
|
70
|
+
plus an ActionCable concern implementing the full y-websocket sync protocol and
|
|
71
|
+
awareness. It lets a Rails app be the collaboration server for Y.js editors (Tiptap,
|
|
72
|
+
ProseMirror, BlockNote) with no Node sidecar.
|
|
73
|
+
email:
|
|
74
|
+
- johnpcamara@gmail.com
|
|
75
|
+
executables: []
|
|
76
|
+
extensions: []
|
|
77
|
+
extra_rdoc_files: []
|
|
78
|
+
files:
|
|
79
|
+
- CHANGELOG.md
|
|
80
|
+
- LICENSE
|
|
81
|
+
- README.md
|
|
82
|
+
- lib/yrb-lite.rb
|
|
83
|
+
- lib/yrb_lite.rb
|
|
84
|
+
- lib/yrb_lite/3.4/yrb_lite.so
|
|
85
|
+
- lib/yrb_lite/4.0/yrb_lite.so
|
|
86
|
+
- lib/yrb_lite/sync.rb
|
|
87
|
+
- lib/yrb_lite/version.rb
|
|
88
|
+
homepage: https://github.com/jpcamara/yrb-lite
|
|
89
|
+
licenses:
|
|
90
|
+
- MIT
|
|
91
|
+
metadata:
|
|
92
|
+
source_code_uri: https://github.com/jpcamara/yrb-lite
|
|
93
|
+
changelog_uri: https://github.com/jpcamara/yrb-lite/blob/main/CHANGELOG.md
|
|
94
|
+
bug_tracker_uri: https://github.com/jpcamara/yrb-lite/issues
|
|
95
|
+
rubygems_mfa_required: 'true'
|
|
96
|
+
post_install_message:
|
|
97
|
+
rdoc_options: []
|
|
98
|
+
require_paths:
|
|
99
|
+
- lib
|
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '3.4'
|
|
105
|
+
- - "<"
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: 4.1.dev
|
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '0'
|
|
113
|
+
requirements: []
|
|
114
|
+
rubygems_version: 3.5.23
|
|
115
|
+
signing_key:
|
|
116
|
+
specification_version: 4
|
|
117
|
+
summary: Thread-safe Ruby bindings for y-crdt (Y.js) with the y-websocket sync protocol
|
|
118
|
+
for ActionCable
|
|
119
|
+
test_files: []
|