yrb-lite-actioncable 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: ff73c1e1f2a6eba76885f3dc424b6dd6a270a996bdaf90ad4d27a4f0bdac4b70
4
+ data.tar.gz: e24b4854df8b61d32f4aab79988dbfe5c4aaa82dd0bf7aec1498db490cf2c2fa
5
+ SHA512:
6
+ metadata.gz: 9856087504c7d4ed245a424f8c4cd8f11b4d8cfba0f485ca2c14b5db988ece95d49753c0d0bb81fc5d0f8876c00b946b78a45ebe81a28da56863a789a552afd5
7
+ data.tar.gz: 076a78ddd1777657a93060d6221424b1a5fd1c785fafa25b65262b16b5046200d49c2feeec5c5031be7b4232435836fe993aefb0c13ae36c762cc88d5f92f166
@@ -0,0 +1,26 @@
1
+ # Changelog — yrb-lite-actioncable
2
+
3
+ All notable changes to the `yrb-lite-actioncable` gem are documented here. The
4
+ format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
+ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0.beta1] - 2026-06-18
10
+
11
+ ### Added
12
+
13
+ - Initial release, extracted from `yrb-lite` (which shipped this as
14
+ `YrbLite::Sync` through 0.1.0.beta4). Provides `YrbLite::ActionCable::Sync`, an
15
+ ActionCable channel concern implementing the y-websocket sync protocol and
16
+ awareness/presence over ActionCable and AnyCable: record-before-distribute
17
+ auditing (`on_change`), persistence hooks (`on_load`/`on_save`), `:memory` and
18
+ `:store` backends, presence reaping, idle-document eviction, and multi-process
19
+ replica sync. Depends on `yrb-lite` (>= 0.1.0.beta5) for the CRDT documents,
20
+ awareness, and protocol primitives.
21
+ - `on_change` recorders run in the channel instance's context (carried over from
22
+ `yrb-lite` 0.1.0.beta4), so a recorder can call the channel's own methods
23
+ directly.
24
+
25
+ [Unreleased]: https://github.com/jpcamara/yrb-lite/commits/main
26
+ [0.1.0.beta1]: https://github.com/jpcamara/yrb-lite/releases/tag/v0.1.0.beta5
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,477 @@
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
+ #### Reliable delivery (acks)
296
+
297
+ The y-websocket protocol is fire-and-forget. If a client's update is lost in
298
+ transit (a flaky socket, a send that never lands) and the client makes no
299
+ further edits, the server stays idle and never asks anyone to resync, so that
300
+ edit is gone -- even though the client believes it was saved. CRDTs converge the
301
+ state everyone *has*; they don't recover an update that never arrived.
302
+
303
+ yrb-lite closes that gap with an opt-in, client-driven acknowledgement. If an
304
+ incoming frame carries an `"id"`, the server replies `{ "ack": <id> }` once the
305
+ update has been **accepted** -- recorded in audit mode, applied in fast mode. A
306
+ causally-gapped update is not acked (it gets a resync instead), so the client
307
+ knows it hasn't landed yet.
308
+
309
+ ```
310
+ client -> server { "m": "<base64 update>", "id": 42 }
311
+ server -> client { "ack": 42 } # update accepted; safe to forget
312
+ ```
313
+
314
+ That's the whole server side. A reliable client tags each outgoing update with
315
+ an incrementing id, keeps it in a pending buffer, and retransmits on a timer (and
316
+ on reconnect) until the matching ack returns. Because CRDT apply is idempotent, a
317
+ resend that already landed is a harmless no-op that just re-acks. An update lost
318
+ in transit is recovered by the client's own retransmit -- no reconnect required,
319
+ and no dependence on a later edit happening to trigger a resync.
320
+
321
+ This is entirely **self-gating**: stock clients send no `"id"`, so they never get
322
+ acks and behave exactly as before. Only a client that opts in by tagging its
323
+ frames participates.
324
+
325
+ Two client examples ship in the demo:
326
+
327
+ - [`frontend/reliable.mjs`](examples/actioncable-demo/frontend/reliable.mjs) — a
328
+ minimal reference client showing the raw mechanism (tag with an id, retain,
329
+ retransmit on a timer, drain on ack), with an end-to-end test that loses an
330
+ update mid-flight and recovers it purely by retransmit.
331
+ - [`frontend/provider/reliable_actioncable_provider.mjs`](examples/actioncable-demo/frontend/provider/reliable_actioncable_provider.mjs)
332
+ — the standard `@y-rb/actioncable` `WebsocketProvider`, vendored and augmented
333
+ for production use. It's a drop-in replacement that speaks the same protocol
334
+ and envelope, and adds reliability with **sync-since-last-ack** framing: rather
335
+ than retransmitting updates one by one, it keeps the unacknowledged local
336
+ updates in a queue and sends their *merge* as a single causally-complete delta,
337
+ with the id being the highest sequence in the batch (so one `{ ack: id }`
338
+ cumulatively confirms everything up to it). Because the whole unacked tail goes
339
+ as one self-contained delta, the server never sees an internal gap and never
340
+ has to round-trip a resync for a lost middle update — the next edit, or the
341
+ next timer tick, carries it. Awareness stays fire-and-forget; against a server
342
+ that doesn't implement acks it warns once and falls back to plain delivery; and
343
+ `reliable: false` opts out entirely. The demo's editor uses this provider.
344
+
345
+ ### User Awareness/Presence
346
+
347
+ ```ruby
348
+ # Set local user state (cursor position, name, etc.)
349
+ awareness.set_local_state('{"user": {"name": "Alice", "color": "#ff0000"}}')
350
+
351
+ # Get local state
352
+ awareness.local_state # => '{"user": {"name": "Alice", "color": "#ff0000"}}'
353
+
354
+ # Clear local state (e.g., when disconnecting)
355
+ awareness.clear_local_state
356
+
357
+ # Encode awareness update for broadcasting
358
+ update = awareness.encode_awareness_update
359
+ ```
360
+
361
+ ### Low-Level Access
362
+
363
+ ```ruby
364
+ # Get state vector for manual sync
365
+ sv = awareness.encode_state_vector
366
+
367
+ # Get update diffed against a state vector
368
+ update = awareness.encode_state_as_update(remote_state_vector)
369
+
370
+ # Apply raw update to the document
371
+ awareness.apply_update(update_bytes)
372
+
373
+ # Wrap raw update data in a sync message
374
+ message = awareness.encode_update(update_bytes)
375
+ ```
376
+
377
+ ## Thread Safety
378
+
379
+ Unlike the official `y-rb` gem, yrb-lite is safe to share across Ruby threads. A
380
+ `Doc` or `Awareness` can be used concurrently from Puma workers, ActionCable
381
+ connection threads, or background jobs without external locking.
382
+
383
+ That comes from how the underlying types work, not from locking on top:
384
+
385
+ - `yrs::Doc` is `Send + Sync`. Every operation takes the document's internal
386
+ RwLock with blocking semantics (`read_blocking`/`write_blocking`), so
387
+ concurrent access serializes instead of erroring or corrupting state.
388
+ - `yrs::sync::Awareness` is built for multi-threaded servers: client states
389
+ live in a `DashMap` and the whole API is `&self`.
390
+ - The extension adds no interior-mutability tricks. There's no `RefCell`, where
391
+ a re-entrant borrow would panic and take the Ruby process down with it.
392
+ Each native method opens and closes its transaction in one call, so no lock
393
+ or borrow outlives a call and there's nothing to deadlock on.
394
+ - A `Send + Sync` static assertion for both wrapped types lives in `lib.rs`. If
395
+ a yrs upgrade regressed this, the gem would fail to compile instead of quietly
396
+ turning thread-unsafe.
397
+
398
+ `test/thread_safety_test.rb` runs shared docs, the full sync handshake, fan-in
399
+ sync, and awareness state across 8 threads at once, and checks the interleaving
400
+ doesn't change convergence.
401
+
402
+ ### Parallelism (GVL release)
403
+
404
+ Every method that does real CRDT work (applying updates, encoding state,
405
+ handling sync messages) releases Ruby's Global VM Lock
406
+ (`rb_thread_call_without_gvl`) while the native code runs. That buys two things.
407
+
408
+ CRDT work runs in parallel across Ruby threads on MRI, not just
409
+ JRuby/TruffleRuby. `bench/parallelism_bench.rb` measures over 2x wall-clock
410
+ speedup applying a ~900 KB update concurrently; native code that held the GVL
411
+ couldn't beat serial time.
412
+
413
+ A slow operation also can't stall the VM. A thread applying a large update holds
414
+ the doc's write lock without holding the GVL, so other Ruby threads keep running
415
+ instead of queuing behind it.
416
+
417
+ Each method has the same shape: copy the Ruby byte string, drop the GVL, do the
418
+ yrs work (taking and releasing the doc lock entirely inside the closure), take
419
+ the GVL back, then build Ruby objects. No Ruby API is touched without the GVL,
420
+ and the doc lock is never held across a GVL boundary, so the lock order can't
421
+ deadlock. Panics in native code are caught and re-raised as Ruby exceptions.
422
+
423
+ ## Message Type Constants
424
+
425
+ ```ruby
426
+ YrbLite::MSG_SYNC # 0 - Document sync messages
427
+ YrbLite::MSG_AWARENESS # 1 - User presence data
428
+ YrbLite::MSG_AUTH # 2 - Authentication
429
+ YrbLite::MSG_QUERY_AWARENESS # 3 - Request awareness state
430
+
431
+ YrbLite::MSG_SYNC_STEP1 # 0 - State vector request
432
+ YrbLite::MSG_SYNC_STEP2 # 1 - Update response
433
+ YrbLite::MSG_SYNC_UPDATE # 2 - Incremental update
434
+ ```
435
+
436
+ ## Sync Flow
437
+
438
+ ```
439
+ Client A Server
440
+ | |
441
+ |-------- start() --------------->|
442
+ | (SyncStep1 + Awareness) |
443
+ | |
444
+ |<------- handle() response ------|
445
+ | (SyncStep2) |
446
+ | |
447
+ | (Document synchronized!) |
448
+ | |
449
+ |<------- updates ----------------|
450
+ |-------- updates --------------->|
451
+ ```
452
+
453
+ ## Development
454
+
455
+ ```bash
456
+ # Setup
457
+ bundle install
458
+
459
+ # Build extension
460
+ rake compile
461
+
462
+ # Run tests
463
+ rake test
464
+
465
+ # Clean build artifacts
466
+ rake clean
467
+ ```
468
+
469
+ ## License
470
+
471
+ MIT License
472
+
473
+ ## Acknowledgments
474
+
475
+ - [y-crdt/yrs](https://github.com/y-crdt/y-crdt) - The Rust implementation of Y.js
476
+ - [Magnus](https://github.com/matsadler/magnus) - Ruby bindings for Rust
477
+ - [rb-sys](https://github.com/oxidize-rb/rb-sys) - Rust extensions for Ruby
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `Bundler.require` works out of the box.
4
+ require "yrb_lite/action_cable"
@@ -0,0 +1,581 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yrb_lite"
4
+ require "base64"
5
+ require "securerandom"
6
+
7
+ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
8
+ # y-websocket protocol over ActionCable.
9
+ #
10
+ # Include this module in an ActionCable channel to sync Y.js documents
11
+ # (and awareness/presence) with browser clients. Messages are the standard
12
+ # y-protocols binary messages, base64-encoded in a JSON envelope:
13
+ #
14
+ # { "m" => "<base64 bytes>" } # client -> server
15
+ # { "m" => "...", "origin" => "<id>" } # server -> subscribers
16
+ #
17
+ # Example:
18
+ # class DocumentChannel < ApplicationCable::Channel
19
+ # include YrbLite::ActionCable::Sync
20
+ #
21
+ # on_load { |key| Document.find_by(key: key)&.content }
22
+ # on_save { |key, update| Document.find_by(key: key)&.update!(content: update) }
23
+ #
24
+ # # on_change blocks run in the channel instance's context, so instance
25
+ # # methods (current_user, params, ...) are available without plumbing:
26
+ # on_change { |key, update| Document.record!(key, update, by: current_user) }
27
+ #
28
+ # def subscribed
29
+ # sync_for params[:id]
30
+ # end
31
+ #
32
+ # def receive(data)
33
+ # sync_receive(data)
34
+ # end
35
+ #
36
+ # def unsubscribed
37
+ # sync_clear_presence
38
+ # end
39
+ # end
40
+ #
41
+ # The shared YrbLite::Awareness instances are safe to use from ActionCable's
42
+ # worker thread pool: the native types are Send + Sync and every operation
43
+ # releases the GVL, so concurrent clients sync in parallel.
44
+ module Sync
45
+ # Validated frame kinds from Awareness#message_kind. A frame only gets a
46
+ # non-DROP kind if it is exactly one well-formed message; anything
47
+ # malformed, truncated, multi-message, or unknown is dropped before it can
48
+ # be processed or relayed.
49
+ MSG_KIND_DROP = 0
50
+ MSG_KIND_SYNC_STEP1 = 1
51
+ MSG_KIND_UPDATE = 2
52
+ MSG_KIND_AWARENESS = 3
53
+ MSG_KIND_AWARENESS_QUERY = 4
54
+
55
+ def self.included(base)
56
+ base.extend(ClassMethods)
57
+ end
58
+
59
+ module ClassMethods
60
+ # Load persisted document state. Called once per key with (key);
61
+ # return a binary Y.js update (or nil for a fresh document).
62
+ def on_load(callable = nil, &block)
63
+ @on_load = callable || block if callable || block
64
+ @on_load
65
+ end
66
+
67
+ # Persist document state. Called with (key, update) after every
68
+ # message that modified the document.
69
+ def on_save(callable = nil, &block)
70
+ @on_save = callable || block if callable || block
71
+ @on_save
72
+ end
73
+
74
+ # Record every document change durably before it is applied or
75
+ # distributed (authoritative audit mode). Called synchronously with
76
+ # (key, update), where update is the exact CRDT delta, serialized per
77
+ # document so the recorded order is the apply order. If the block raises,
78
+ # the change is rejected: neither applied to the shared document nor
79
+ # broadcast to other subscribers.
80
+ #
81
+ # A block recorder runs in the *channel instance's* context, so it can
82
+ # call the channel's own methods (current_user, params, a per-connection
83
+ # Current.* accessor) directly, with no thread-local plumbing. (A non-Proc
84
+ # callable is invoked with #call instead, since it carries its own
85
+ # context.) on_change always fires from within sync_receive, unlike
86
+ # on_load/on_save, which can run context-free in the shared registry.
87
+ #
88
+ # Registering an on_change switches that channel onto the strict path
89
+ # (record, apply, broadcast). Without it, the default fast path applies
90
+ # and broadcasts, with an optional on_save snapshot.
91
+ def on_change(callable = nil, &block)
92
+ @on_change = callable || block if callable || block
93
+ @on_change
94
+ end
95
+
96
+ # Select the document backend:
97
+ # :memory (default): keep a warm in-memory replica per process and keep
98
+ # it current via a custom stream_from callback. Fast, but it assumes
99
+ # classic ActionCable (the callback runs in Ruby) and
100
+ # process<->document affinity.
101
+ # :store: stateless per message, with no warm replica and no custom
102
+ # stream callback. Handshakes and reads are served from the durable
103
+ # store (`on_load`); changes are recorded (`on_change`) and relayed.
104
+ # Works under AnyCable (broadcasts handled outside Ruby, no worker
105
+ # affinity) and across processes. Requires `on_load` and `on_change`.
106
+ def sync_backend(mode = nil)
107
+ @sync_backend = mode if mode
108
+ @sync_backend || :memory
109
+ end
110
+ end
111
+
112
+ # Call from `subscribed`. Streams broadcasts for this document and
113
+ # transmits the server's opening handshake (SyncStep1 + awareness).
114
+ def sync_for(key)
115
+ @sync_key = key.to_s
116
+ @sync_origin = SecureRandom.hex(8)
117
+ @sync_clients = [] # awareness client IDs seen on this connection
118
+
119
+ return sync_for_store_backed if self.class.sync_backend == :store
120
+
121
+ Sync.subscribe(@sync_key)
122
+ awareness = sync_awareness
123
+
124
+ stream_from sync_stream_name, coder: ActiveSupport::JSON do |payload|
125
+ sync_on_broadcast(payload)
126
+ end
127
+
128
+ # Opening handshake: SyncStep1 then the current awareness, each as its
129
+ # own single-message frame, so providers that parse one message per frame
130
+ # (e.g. @y-rb/actioncable) handle both. The client replies SyncStep2 to
131
+ # the SyncStep1, delivering its state to the server.
132
+ sync_transmit(awareness.sync_step1)
133
+ sync_transmit(awareness.encode_awareness_update)
134
+ end
135
+
136
+ # Call from `receive`. Applies the client's message, replies directly
137
+ # when the protocol calls for it, and relays document/awareness changes
138
+ # to the other subscribers.
139
+ #
140
+ # If an `on_change` recorder is registered, document changes take the
141
+ # strict authoritative path (record -> apply -> broadcast, serialized per
142
+ # document); otherwise the fast path is used.
143
+ #
144
+ # Reliable delivery (opt-in, client-driven): if the frame carries an "id",
145
+ # the server replies `{ "ack" => id }` once the update has been accepted
146
+ # (recorded in audit mode, applied in fast mode). A causally-gapped update
147
+ # is not acked -- it gets a resync instead -- so an ack-aware client knows
148
+ # to retransmit until the update lands. Stock clients send no "id", never
149
+ # get acks, and are completely unaffected.
150
+ def sync_receive(data, key = nil)
151
+ # Pass `key` (params[:id]) when your transport doesn't keep the channel
152
+ # instance alive across actions. Under AnyCable each RPC command gets a
153
+ # fresh channel, so instance variables set in `subscribed` are gone here.
154
+ @sync_key = key.to_s if key
155
+
156
+ # Accept both envelope keys: "m" (yrb-lite's own clients) and "update"
157
+ # (the @y-rb/actioncable browser provider).
158
+ m = data.is_a?(Hash) ? (data["m"] || data["update"]) : nil
159
+ return unless m.is_a?(String)
160
+
161
+ # Optional client-supplied id for reliable delivery (see sync_send_ack).
162
+ id = data.is_a?(Hash) ? data["id"] : nil
163
+
164
+ begin
165
+ bytes = Base64.strict_decode64(m)
166
+ rescue ArgumentError
167
+ return # not valid base64; ignore the frame and keep the connection
168
+ end
169
+
170
+ sync_send_ack(id, sync_dispatch(m, bytes))
171
+ end
172
+
173
+ # Route a decoded frame to the backend/path that handles it and return the
174
+ # outcome symbol (:recorded/:applied/:gap/:noop) used by the reliable-
175
+ # delivery ack. A dropped frame returns nil (never acked).
176
+ def sync_dispatch(encoded, bytes)
177
+ return sync_receive_store_backed(encoded, bytes) if self.class.sync_backend == :store
178
+
179
+ awareness = sync_awareness
180
+ kind = awareness.message_kind(bytes)
181
+ # Malformed / truncated / multi-message / unknown frames are dropped
182
+ # before they can be processed or relayed to other clients.
183
+ return if kind == MSG_KIND_DROP
184
+
185
+ sync_track_clients(awareness, bytes) if kind == MSG_KIND_AWARENESS
186
+
187
+ if kind == MSG_KIND_UPDATE && self.class.on_change
188
+ sync_apply_authoritative(awareness, encoded, bytes)
189
+ else
190
+ sync_apply_fast(awareness, encoded, bytes, kind)
191
+ end
192
+ end
193
+
194
+ # Call from `unsubscribed`. Clears the presence states this connection
195
+ # introduced and tells the other subscribers to drop those cursors, so a
196
+ # closed tab or dropped socket doesn't leave a ghost cursor behind until
197
+ # the client-side timeout reaps it.
198
+ def sync_clear_presence
199
+ return if @sync_clients.nil? || @sync_clients.empty?
200
+
201
+ removal = sync_awareness.remove_clients(@sync_clients)
202
+ @sync_clients = []
203
+ return if removal.empty?
204
+
205
+ sync_distribute(Base64.strict_encode64(removal))
206
+ end
207
+
208
+ # Call from `unsubscribed`. Clears this connection's presence and, when the
209
+ # last subscriber for the document leaves, persists and unloads it from
210
+ # memory (only when an `on_load` is configured to bring it back; otherwise
211
+ # the in-memory document is the only copy and is kept). Prevents a
212
+ # long-running server from accumulating every document it has ever served.
213
+ def sync_unsubscribed(key = nil)
214
+ @sync_key = key.to_s if key
215
+ return if self.class.sync_backend == :store # nothing cached per process
216
+
217
+ sync_clear_presence
218
+ saver = self.class.on_save
219
+ Sync.release(@sync_key, evictable: !self.class.on_load.nil?) do |awareness|
220
+ saver&.call(@sync_key, awareness.encode_state_as_update)
221
+ end
222
+ end
223
+
224
+ # The shared Awareness (document + presence) for this channel's key.
225
+ # Also useful for server-side reads, e.g.:
226
+ # sync_awareness.encode_state_as_update
227
+ def sync_awareness
228
+ Sync.awareness_for(@sync_key, self.class.on_load)
229
+ end
230
+
231
+ private
232
+
233
+ # Default path: apply the message, answer direct requests, relay
234
+ # state-changing messages to the other subscribers. Routing comes from the
235
+ # native `kind` (from Awareness#message_kind) rather than peeking at bytes.
236
+ # Document changes (SyncStep2, Update) and awareness get relayed; requests
237
+ # (SyncStep1, awareness-query) are answered above and not relayed. An
238
+ # optional on_save snapshot is taken after a document change.
239
+ #
240
+ # Returns an outcome symbol for the reliable-delivery ack: :applied when a
241
+ # document update was integrated and relayed, :gap when it was rejected for
242
+ # a resync, :noop for everything else (requests, awareness, empty updates).
243
+ def sync_apply_fast(awareness, encoded, bytes, kind)
244
+ # A document update that isn't causally ready (an earlier one was lost in
245
+ # transit) would relay an un-integrable change to peers and stall the
246
+ # replica. Drop it and ask the client to resync instead, which re-delivers
247
+ # the missing piece. See sync_apply_authoritative for the durable variant.
248
+ if kind == MSG_KIND_UPDATE
249
+ update = awareness.update_from_message(bytes)
250
+ # A no-op message (e.g. the empty SyncStep2 in an opening handshake)
251
+ # carries no change, so there's nothing to relay, persist, or ack.
252
+ return :noop unless update
253
+
254
+ unless awareness.update_ready?(update)
255
+ sync_request_resync(awareness)
256
+ return :gap
257
+ end
258
+ end
259
+
260
+ response = awareness.handle(bytes)
261
+ sync_transmit(response) unless response.empty?
262
+
263
+ return :noop unless [MSG_KIND_UPDATE, MSG_KIND_AWARENESS].include?(kind)
264
+
265
+ sync_distribute(encoded)
266
+ return :noop unless kind == MSG_KIND_UPDATE
267
+
268
+ sync_persist
269
+ :applied
270
+ end
271
+
272
+ # Authoritative path: record the change durably, then apply it to the
273
+ # shared document, then distribute it. The sequence runs under a
274
+ # per-document lock so changes are recorded in a single total order that
275
+ # matches the order they're applied, and nothing is distributed (or applied)
276
+ # before it has been recorded. If the recorder raises, the change is
277
+ # rejected (not applied, not broadcast) and the exception propagates, so the
278
+ # channel can surface it and the client can resync.
279
+ #
280
+ # Before recording, the update must be causally ready: every dependency it
281
+ # references must already be in the doc. If an earlier update was lost in
282
+ # transit, or its record failed, a later update arrives with a gap. Recording
283
+ # it would write a permanently-pending entry to the log -- one that can never
284
+ # be replayed until the missing update shows up. Such an update is rejected
285
+ # (not recorded, not applied, not relayed) and the client is asked to resync,
286
+ # which re-delivers the missing range as one causally-complete delta.
287
+ def sync_apply_authoritative(awareness, encoded, bytes)
288
+ recorder = self.class.on_change
289
+
290
+ outcome = Sync.lock_for(@sync_key).synchronize do
291
+ update = awareness.update_from_message(bytes)
292
+ # A no-op message (e.g. the empty SyncStep2 in a client's opening
293
+ # handshake) carries no change, so there's nothing to record or relay.
294
+ next :noop unless update
295
+ next :gap unless awareness.update_ready?(update)
296
+
297
+ sync_record_change(recorder, update) # durable write; raise to reject
298
+ awareness.apply_update(update) # only recorded changes reach the doc
299
+ sync_distribute(encoded) # ...and only then the wire
300
+ :recorded
301
+ end
302
+
303
+ case outcome
304
+ when :recorded then sync_persist
305
+ when :gap then sync_request_resync(awareness)
306
+ end
307
+
308
+ # Surface the outcome for the reliable-delivery ack: :recorded means the
309
+ # update is durably written (and will be acked); :gap triggered a resync
310
+ # (no ack); :noop carried no change.
311
+ outcome
312
+ end
313
+
314
+ # Ask this connection's client to resync: re-send SyncStep1 carrying the
315
+ # server's current (gap-free) state vector. The client replies SyncStep2
316
+ # with everything the server is missing, delivered as one causally-complete
317
+ # delta -- which heals the gap that triggered the resync.
318
+ def sync_request_resync(awareness)
319
+ sync_transmit(awareness.sync_step1)
320
+ end
321
+
322
+ # Reliable delivery: acknowledge an accepted update back to the sending
323
+ # connection. An ack-aware client tags each outgoing update with an "id"
324
+ # and retains it until the matching `{ "ack" => id }` returns, retransmitting
325
+ # on a timer or reconnect; idempotent CRDT apply makes resends free. We ack
326
+ # only when the client supplied an id (so stock clients are unaffected) and
327
+ # the update was actually accepted -- recorded in audit mode, applied in fast
328
+ # mode. A gapped update gets no ack (it got a resync), so the client keeps
329
+ # retransmitting until the missing range lands and the update can integrate.
330
+ def sync_send_ack(id, outcome)
331
+ return if id.nil?
332
+ return unless %i[recorded applied].include?(outcome)
333
+
334
+ # Braces are load-bearing: a bare hash would bind to transmit's `via:`
335
+ # keyword instead of its positional data argument.
336
+ transmit({ "ack" => id })
337
+ end
338
+
339
+ # Single broadcast point for both paths (and presence removal), so the
340
+ # relay semantics live in one place and tests can observe distribution.
341
+ # `origin` identifies the sending connection (don't echo to it); `pid`
342
+ # identifies the sending process (other processes apply it to their own
343
+ # replica; see sync_on_broadcast).
344
+ def sync_distribute(encoded)
345
+ ActionCable.server.broadcast(
346
+ sync_stream_name,
347
+ sync_envelope(encoded, "origin" => @sync_origin, "pid" => Sync.process_id)
348
+ )
349
+ end
350
+
351
+ # Transmit raw protocol bytes to this connection (base64, dual-key).
352
+ def sync_transmit(bytes)
353
+ transmit(sync_envelope(Base64.strict_encode64(bytes)))
354
+ end
355
+
356
+ # Build an outgoing envelope. We send the payload under both keys: "m"
357
+ # (yrb-lite's own clients) and "update" (the @y-rb/actioncable provider),
358
+ # so either client works against the same server.
359
+ def sync_envelope(encoded, extra = {})
360
+ { "m" => encoded, "update" => encoded }.merge(extra)
361
+ end
362
+
363
+ # Handle a broadcast delivered by the cable adapter. With a multi-process
364
+ # adapter (Redis, solid_cable), it may have come from another server
365
+ # process. Keep this process's in-memory replica current with changes that
366
+ # originated elsewhere, then relay to this connection's browser.
367
+ def sync_on_broadcast(payload)
368
+ sync_apply_remote(payload["m"]) if payload["pid"] != Sync.process_id
369
+ transmit(payload) unless payload["origin"] == @sync_origin
370
+ end
371
+
372
+ # Apply a change that originated on another process to this process's
373
+ # replica, without re-recording it (the origin process already recorded it
374
+ # before broadcasting). The CRDT merge is idempotent and commutative, so a
375
+ # cold replica converges regardless of ordering, and applying from several
376
+ # local connections is harmless.
377
+ def sync_apply_remote(encoded)
378
+ return unless encoded.is_a?(String)
379
+
380
+ begin
381
+ bytes = Base64.strict_decode64(encoded)
382
+ rescue ArgumentError
383
+ return
384
+ end
385
+
386
+ awareness = sync_awareness
387
+ case awareness.message_kind(bytes)
388
+ when MSG_KIND_UPDATE
389
+ update = awareness.update_from_message(bytes)
390
+ awareness.apply_update(update) if update
391
+ when MSG_KIND_AWARENESS
392
+ awareness.handle(bytes)
393
+ end
394
+ end
395
+
396
+ # -- Store-backed (AnyCable-native) path --------------------------------
397
+
398
+ # Subscribe without a custom block, so AnyCable (which delivers broadcasts
399
+ # outside Ruby) relays them directly. Send the opening SyncStep1 built from
400
+ # the durable store. No warm replica is kept.
401
+ def sync_for_store_backed
402
+ stream_from sync_stream_name
403
+ sync_transmit(sync_load_doc.sync_step1)
404
+ end
405
+
406
+ # Stateless per message: no warm replica, no assumptions about which process
407
+ # owns a document. A client's SyncStep1 is answered from the store, document
408
+ # changes are recorded durably before relay and then broadcast, and
409
+ # awareness is relayed best-effort. Echoing back to the sender is harmless,
410
+ # since the CRDT apply is idempotent.
411
+ #
412
+ # Returns an outcome symbol for the reliable-delivery ack: :recorded when a
413
+ # document update was durably recorded and relayed, :gap when it was
414
+ # rejected for a resync, :noop for everything else.
415
+ def sync_receive_store_backed(encoded, bytes)
416
+ case Sync.codec.message_kind(bytes)
417
+ when MSG_KIND_SYNC_STEP1
418
+ result = sync_load_doc.handle_sync_message(bytes)
419
+ sync_transmit(result[2]) if result
420
+ :noop
421
+ when MSG_KIND_UPDATE
422
+ update = Sync.codec.update_from_message(bytes)
423
+ return :noop unless update
424
+
425
+ # Store mode keeps no warm replica, so to tell whether this update is
426
+ # causally ready we rebuild the doc from the store and check against it.
427
+ # That's an O(history) load per update (mitigated by snapshotting the
428
+ # store on the load path). A gappy update -- an earlier one was lost or
429
+ # its record failed -- is rejected and the client asked to resync,
430
+ # rather than written to the log as a permanently-pending entry.
431
+ doc = sync_load_doc
432
+ unless doc.update_ready?(update)
433
+ sync_transmit(doc.sync_step1)
434
+ return :gap
435
+ end
436
+
437
+ if (recorder = self.class.on_change)
438
+ sync_record_change(recorder, update) # record before relay
439
+ end
440
+ sync_distribute(encoded)
441
+ :recorded
442
+ when MSG_KIND_AWARENESS
443
+ sync_distribute(encoded)
444
+ :noop
445
+ else
446
+ :noop
447
+ end
448
+ end
449
+
450
+ # Build a fresh document from the durable store (on_load).
451
+ def sync_load_doc
452
+ doc = YrbLite::Doc.new
453
+ state = self.class.on_load&.call(@sync_key)
454
+ doc.apply_update(state) if state
455
+ doc
456
+ end
457
+
458
+ # Record the awareness client IDs carried by an incoming message (already
459
+ # known to be an awareness frame) so we can clear them when this connection
460
+ # closes.
461
+ def sync_track_clients(awareness, bytes)
462
+ awareness.awareness_client_ids(bytes).each do |id|
463
+ @sync_clients << id unless @sync_clients.include?(id)
464
+ end
465
+ end
466
+
467
+ def sync_stream_name
468
+ "yrb_lite:#{@sync_key}"
469
+ end
470
+
471
+ def sync_persist
472
+ return unless (saver = self.class.on_save)
473
+
474
+ saver.call(@sync_key, sync_awareness.encode_state_as_update)
475
+ end
476
+
477
+ # Invoke the on_change recorder. A block/proc runs in this channel instance's
478
+ # context (instance_exec) so it can reach the channel's own methods; a
479
+ # non-Proc callable is invoked with #call, since it carries its own context.
480
+ def sync_record_change(recorder, update)
481
+ args = [@sync_key, update]
482
+ recorder.is_a?(Proc) ? instance_exec(*args, &recorder) : recorder.call(*args)
483
+ end
484
+
485
+ # -- Shared document registry ------------------------------------------
486
+
487
+ @registry = {}
488
+ @locks = {}
489
+ @subscribers = Hash.new(0)
490
+ @registry_mutex = Mutex.new
491
+
492
+ class << self
493
+ # A stable id for this server process, stamped on every broadcast so
494
+ # other processes know to apply it to their replica and this process
495
+ # knows to skip its own. Survives for the life of the process.
496
+ def process_id
497
+ @process_id ||= SecureRandom.hex(8)
498
+ end
499
+
500
+ # A shared, stateless decoder for the store-backed path. message_kind and
501
+ # update_from_message only read their argument (they don't touch the
502
+ # instance's document), so one shared instance is safe across threads.
503
+ def codec
504
+ @codec ||= YrbLite::Awareness.new
505
+ end
506
+
507
+ # Get or create the shared Awareness for a key. Creation (including
508
+ # the on_load callback) is serialized under a mutex so concurrent
509
+ # subscribers can never observe two documents for one key; all
510
+ # subsequent operations run lock-free on the thread-safe native types.
511
+ def awareness_for(key, loader = nil)
512
+ @registry_mutex.synchronize do
513
+ @registry[key] ||= begin
514
+ awareness = YrbLite::Awareness.new
515
+ if loader && (state = loader.call(key))
516
+ awareness.apply_update(state)
517
+ end
518
+ awareness
519
+ end
520
+ end
521
+ end
522
+
523
+ # Per-document mutex serializing the authoritative record -> apply ->
524
+ # broadcast section, so a document's audit log is a single total order.
525
+ # Only briefly holds the registry mutex to fetch/create the lock; the
526
+ # durable write itself runs while holding only this per-key lock.
527
+ def lock_for(key)
528
+ @registry_mutex.synchronize { @locks[key] ||= Mutex.new }
529
+ end
530
+
531
+ # Count a new subscriber for a document.
532
+ def subscribe(key)
533
+ @registry_mutex.synchronize { @subscribers[key] += 1 }
534
+ end
535
+
536
+ # Drop a subscriber. When the last one leaves and the document is
537
+ # evictable (there's an on_load to bring it back, so unloading can't lose
538
+ # data), persist it via the given block and unload it from memory, so a
539
+ # long-running server doesn't accumulate every document and lock it has
540
+ # ever seen. Returns true if the document was evicted.
541
+ #
542
+ # The persist runs outside the registry lock (it may do I/O), and we
543
+ # re-check the subscriber count afterward: if someone reconnected while
544
+ # we were saving, eviction is aborted and the warm document is kept.
545
+ def release(key, evictable:)
546
+ awareness = @registry_mutex.synchronize do
547
+ @subscribers[key] -= 1 if @subscribers[key].positive?
548
+ next nil unless @subscribers[key].zero?
549
+
550
+ @subscribers.delete(key)
551
+ evictable ? @registry[key] : nil
552
+ end
553
+ return false unless awareness
554
+
555
+ yield awareness if block_given?
556
+
557
+ @registry_mutex.synchronize do
558
+ # A subscriber may have returned during the persist above.
559
+ next false unless @subscribers[key].zero?
560
+
561
+ @subscribers.delete(key)
562
+ @locks.delete(key)
563
+ !@registry.delete(key).nil?
564
+ end
565
+ end
566
+
567
+ def registry
568
+ @registry_mutex.synchronize { @registry.dup }
569
+ end
570
+
571
+ # Clear all documents (useful for testing).
572
+ def reset!
573
+ @registry_mutex.synchronize do
574
+ @registry = {}
575
+ @locks = {}
576
+ @subscribers = Hash.new(0)
577
+ end
578
+ end
579
+ end
580
+ end
581
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YrbLite
4
+ module ActionCable
5
+ VERSION = "0.1.0.beta1"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yrb_lite"
4
+ require "yrb_lite/action_cable/version"
5
+
6
+ module YrbLite
7
+ # ActionCable integration for yrb-lite.
8
+ #
9
+ # Provides YrbLite::ActionCable::Sync, a channel concern implementing the
10
+ # y-websocket sync protocol and awareness/presence over ActionCable (and
11
+ # AnyCable), so a Rails app can be the collaboration server for Y.js editors
12
+ # with no Node sidecar. The CRDT documents, awareness, and protocol primitives
13
+ # themselves come from the core `yrb-lite` gem.
14
+ module ActionCable
15
+ end
16
+ end
17
+
18
+ require "yrb_lite/action_cable/sync"
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yrb-lite-actioncable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.beta1
5
+ platform: ruby
6
+ authors:
7
+ - JP Camara
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: yrb-lite
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0.beta5
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0.beta5
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: 'yrb-lite-actioncable adds a Rails ActionCable channel concern (YrbLite::ActionCable::Sync)
69
+ on top of the yrb-lite y-crdt bindings: the full y-websocket sync protocol, awareness/presence,
70
+ record-before-distribute auditing, and memory/store backends (AnyCable-ready), so
71
+ a Rails app can be the collaboration server for Y.js editors with no Node sidecar.'
72
+ email:
73
+ - johnpcamara@gmail.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG-actioncable.md
79
+ - LICENSE
80
+ - README.md
81
+ - lib/yrb-lite-actioncable.rb
82
+ - lib/yrb_lite/action_cable.rb
83
+ - lib/yrb_lite/action_cable/sync.rb
84
+ - lib/yrb_lite/action_cable/version.rb
85
+ homepage: https://github.com/jpcamara/yrb-lite
86
+ licenses:
87
+ - MIT
88
+ metadata:
89
+ source_code_uri: https://github.com/jpcamara/yrb-lite
90
+ changelog_uri: https://github.com/jpcamara/yrb-lite/blob/main/CHANGELOG-actioncable.md
91
+ bug_tracker_uri: https://github.com/jpcamara/yrb-lite/issues
92
+ rubygems_mfa_required: 'true'
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.4.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.6.9
108
+ specification_version: 4
109
+ summary: 'ActionCable integration for yrb-lite: the y-websocket sync protocol and
110
+ awareness over ActionCable/AnyCable'
111
+ test_files: []