yrb-lite 0.1.0.beta1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 73c6ed02c103b7647be2d6052ff660490088bff3e1dba3a82105f8fb42ffecab
4
+ data.tar.gz: c308cb9c4e426992b1cbc31b2a870dc3ab3863c3a0217949b7744c4bc4c48d91
5
+ SHA512:
6
+ metadata.gz: 8eb77739b34c860f961a850a70ed39f778f0711d83fda715c8447af1c7d81625472697142ce5aaf8b8a339c408992a6ada2d8a176cf03b1145afc62bd5f7aca5
7
+ data.tar.gz: 8886214b02d90bcbe44958deaa0b1b73bd0aa4707f185f34868f5444e99ca8e4ed02dce4100486543fb7dec82d609996de6f90f81acf12fbf1217466115ba985
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/Cargo.toml ADDED
@@ -0,0 +1,3 @@
1
+ [workspace]
2
+ resolver = "2"
3
+ members = ["ext/yrb_lite"]
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
+ [![CI](https://github.com/jpcamara/yrb-lite/actions/workflows/ci.yml/badge.svg)](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
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "yrb_lite"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ authors = ["JP Camara <johnpcamara@gmail.com>"]
6
+ license = "MIT"
7
+ publish = false
8
+
9
+ [lib]
10
+ crate-type = ["cdylib"]
11
+
12
+ [dependencies]
13
+ magnus = "0.8"
14
+ rb-sys = "0.9"
15
+ yrs = { version = "0.21", features = ["sync"] }
16
+ serde_json = "1.0"
17
+
18
+ [dev-dependencies]
19
+ rb-sys-env = "0.1"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("yrb_lite/yrb_lite")