y-ruby-actioncable 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f6045cee5c0df8e1173a6826135235f0ff4b1051a94fab0443445477ad2e883e
4
+ data.tar.gz: f2307d757c48a851ae47277880b40a16ffbd8994cb9f7a3ed8a32e11e11c7c4d
5
+ SHA512:
6
+ metadata.gz: 9c881cc8349796218c3507a9294c824321496fddb9861b05533b329f30017a10aca01bc0287de0952e018e3ecc405f08771232b7d8ab4c3e0021c98165a9a505
7
+ data.tar.gz: 9b8f5adaebc6d6c48e5e062a18e7e24eed6a0e63620b561b4ecf2b42157afb41e8dccab829eb4fc9d41e440ba4f37070654ea8a31c3210b5a87af63fe2920034
@@ -0,0 +1,23 @@
1
+ # Changelog — y-ruby-actioncable
2
+
3
+ All notable changes to the `y-ruby-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.2.0] - 2026-06-28
10
+
11
+ First release under the **`y-ruby-actioncable`** name (previously developed as
12
+ `yrb-lite-actioncable`).
13
+
14
+ ### Changed
15
+ - **Renamed `yrb-lite-actioncable` → `y-ruby-actioncable`.** Channel concern
16
+ `YrbLite::ActionCable::Sync` → **`Y::ActionCable::Sync`**; require
17
+ `require "yrb_lite/action_cable"` → `require "y/action_cable"`. ActionCable
18
+ stream prefix `yrb_lite:` → `y_ruby:`. Depends on `y-ruby >= 0.2.0`.
19
+
20
+ ### Notes
21
+ - Full y-websocket protocol over ActionCable/AnyCable: origin-filtered relay,
22
+ awareness, on_load/on_save persistence hooks, optional record-before-distribute
23
+ audit mode, and AnyCable `sync_backend :store`.
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,413 @@
1
+ # y-ruby
2
+
3
+ [![CI](https://github.com/jpcamara/y-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/jpcamara/y-ruby/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 Y::ActionCable::Sync
13
+
14
+ on_load { |key| MyStore.load(key) }
15
+ on_change { |key, update| MyStore.append(key, update) }
16
+
17
+ def subscribed = sync_subscribed(params[:id])
18
+ def receive(data) = sync_receive(data, params[:id])
19
+ end
20
+ ```
21
+
22
+ On the browser, use the `ActionCableProvider` from the
23
+ [`@y-ruby/client`](https://www.npmjs.com/package/@y-ruby/client) npm package.
24
+ Integrates with any editor that includes Y.js support, such as Tiptap, ProseMirror
25
+ and [Lexxy](https://www.npmjs.com/package/lexxy-realtime).
26
+
27
+ ## Usage
28
+
29
+ Install the gem and npm package:
30
+
31
+ ```
32
+ gem install y-ruby-actioncable # depends on y-ruby
33
+ npm install @y-ruby/client
34
+ ```
35
+
36
+ ## What you get
37
+
38
+ - A thread-safe Ruby `Doc` you can share across Ruby threads/fibers, and native CRDT work
39
+ runs with the GVL released.
40
+ - The y-websocket protocol (document sync plus awareness/presence) as a
41
+ one-include ActionCable concern.
42
+ - Authoritative record-before-distribute semantics: each document change can be
43
+ recorded durably before it goes out to anyone.
44
+ -
45
+
46
+ ## Why "lite"
47
+
48
+ The "lite" is the size of the surface. `y-ruby` binds just the part of `y-crdt` you
49
+ need to *sync and persist* collaborative documents - a `Doc`, awareness, and the
50
+ y-websocket protocol primitives. The Ruby side treats a document as opaque CRDT
51
+ state: it applies updates, answers sync handshakes, and records deltas, but never
52
+ reaches in to read or edit the contents. The browser editor owns the document's
53
+ shape.
54
+
55
+ ## What isn't "lite"
56
+
57
+ The surface area may be "lite", but a core focus is on durability, resiliency, delivery
58
+ guarantees, correctness, and thread safety.
59
+
60
+ Towards that goal, `y-ruby` adds capabilities that may even stand out in the Yjs ecosystem:
61
+
62
+ - Built-in update acknowledgement: the `ActionCableProvider` in `@y-ruby/client` will continue to
63
+ send updates until an ack is received from the server. [`y-ruby-actioncable`](https://rubygems.org/gems/y-ruby-actioncable)
64
+ only sends an ack when applying an update is successful. The goal is at-least-once delivery,
65
+ and because CRDTs are idempotent a duplicate update is effectively a no-op.
66
+ - Gap detection in document updates: before applying an update and sending an ack to the client,
67
+ `y-ruby` checks whether the update results in any causal gap. Ie, an update comes through
68
+ which depends on a previous update that is not yet present in the document. This can result in
69
+ a document stuck with "pending" updates, which will _never_ apply if the missing update is not sent.
70
+ To avoid this, `y-ruby` does not apply the update, and starts a new y-protocol sync with the client.
71
+ That will cause the client to synchronize its document with the server, sending through any updates
72
+ that may have been missed
73
+
74
+ ## What about [yrb](https://github.com/y-crdt/yrb)?
75
+
76
+ `yrb` has a much larger interface that gives you most of the Yjs type system -
77
+ shared text, arrays, maps, XML - to build and query documents in Ruby. It was a great
78
+ inspiration for my use of Yjs in Ruby/Rails, and I originally considered building
79
+ on top of it. There are a few reasons I went with `y-ruby` instead:
80
+
81
+ - `yrb` is largely unmaintained. It was built as an experiment for GitLab, and the original
82
+ author mostly moved onto other projects.
83
+ - [It isn't thread-safe](https://github.com/y-crdt/yrb/issues/72). It segfaults in a threaded
84
+ environment (such as ActionCable...)
85
+ - It's a much larger set of features to maintain, which most people don't need. The vast
86
+ majority of people manipulate Y.js documents in the browser, not from a server-side language.
87
+
88
+ ## Testing
89
+
90
+ Ruby and Rust unit tests cover the core. CI also runs the npm client tests and a
91
+ Rails demo smoke slice against the real ActionCable stack. The demo includes
92
+ heavier local suites for hostile input, crash recovery, multi-browser editing,
93
+ AnyCable, and load testing. The benchmark note below is from a single laptop.
94
+ Issues and PRs are welcome.
95
+
96
+ ## Install
97
+
98
+ ```ruby
99
+ # Core CRDT + protocol primitives:
100
+ gem "y-ruby"
101
+
102
+ # For the Rails/ActionCable server concern (Y::ActionCable::Sync):
103
+ gem "y-ruby-actioncable"
104
+ ```
105
+
106
+ Requires Ruby 3.4 or newer. The release workflow builds precompiled gems for
107
+ Ruby 3.4 and 4.0 across the supported Ruby platforms, with native smoke tests
108
+ on Linux x86_64 and macOS arm64. Installing from a matching platform gem needs
109
+ no Rust; a source build needs [Rust](https://rustup.rs).
110
+
111
+ To work on the gem itself:
112
+
113
+ ```bash
114
+ git clone https://github.com/jpcamara/y-ruby
115
+ cd y-ruby
116
+ bundle install
117
+ bundle exec rake compile test
118
+ ```
119
+
120
+ The rest of the dev setup, plus the demo, is in [CONTRIBUTING.md](CONTRIBUTING.md).
121
+
122
+ ## Docs
123
+
124
+ - The ActionCable concern and a quickstart are [below](#actioncable-integration).
125
+ - [`examples/actioncable-demo`](examples/actioncable-demo): a runnable Rails +
126
+ Tiptap app with collaborative cursors, the AnyCable setup, a Postgres store,
127
+ and the test/load suites.
128
+ - [CHANGELOG.md](CHANGELOG.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
129
+
130
+ ## Usage
131
+
132
+ ### Doc (Low-Level Document Sync)
133
+
134
+ ```ruby
135
+ require "y"
136
+
137
+ # Create docs
138
+ doc = Y::Doc.new # random client ID
139
+ doc = Y::Doc.new(12345) # specific client ID (used for CRDT identity)
140
+
141
+ # Encoding
142
+ doc.encode_state_vector # => current state vector
143
+ doc.encode_state_as_update # => full update
144
+ doc.encode_state_as_update(sv) # => update diff against state vector
145
+
146
+ # Applying updates
147
+ doc.apply_update(update_bytes) # apply raw V1 update
148
+
149
+ # Sync protocol
150
+ doc.sync_step1 # => SyncStep1 message (this doc's state vector)
151
+ doc.handle_sync_message(data) # => [msg_type, sync_type, response]; answers a
152
+ # peer's SyncStep1 with a SyncStep2
153
+ ```
154
+
155
+ ### Protocol codec (module functions)
156
+
157
+ Classifying and unwrapping wire frames is stateless, so it's exposed as
158
+ `Y` module functions rather than a class. The server never holds presence
159
+ or document state to route a frame — presence lives in the browser clients, and
160
+ the server only relays awareness frames opaquely.
161
+
162
+ ```ruby
163
+ Y.message_kind(frame) # => 0 drop / 1 step1 / 2 update / 3 awareness / 4 query
164
+ Y.update_from_message(frame) # => the document delta carried by a frame, or nil
165
+ Y.wrap_update(update_bytes) # => wrap a raw doc update as a sync Update frame
166
+ ```
167
+
168
+ ### ActionCable Integration
169
+
170
+ `Y::ActionCable::Sync` (from the `y-ruby-actioncable` gem) is a channel
171
+ concern that implements the full y-websocket protocol (document sync +
172
+ awareness/presence) over ActionCable:
173
+
174
+ ```ruby
175
+ # app/channels/document_channel.rb
176
+ class DocumentChannel < ApplicationCable::Channel
177
+ include Y::ActionCable::Sync
178
+
179
+ on_load { |key| MyStore.load(key) } # source of truth
180
+ on_change { |key, update| MyStore.append(key, update) } # durable record
181
+
182
+ def subscribed
183
+ sync_subscribed params[:id]
184
+ end
185
+
186
+ def receive(data)
187
+ sync_receive(data, params[:id])
188
+ end
189
+ end
190
+ ```
191
+
192
+ The concern is store-backed. A handshake is answered from `on_load`; document
193
+ changes are checked against that durable state, recorded through `on_change`,
194
+ then broadcast. Nothing authoritative is kept in ActionCable process memory, so
195
+ AnyCable RPC workers, Puma workers, and separate dynos can all handle messages
196
+ for the same document as long as they share the same store and cable adapter.
197
+
198
+ `on_load` and `on_change` are required. If either is missing, the channel fails
199
+ before it can acknowledge or broadcast edits. Presence is ephemeral:
200
+ awareness frames are relayed, and `@y-ruby/client` sends a best-effort
201
+ presence-removal frame on disconnect/pagehide, with the client-side awareness
202
+ timeout as the fallback for abrupt disconnects.
203
+
204
+ Incoming frames are validated as a single well-formed protocol message before
205
+ anything processes or relays them. Malformed, truncated, multi-message,
206
+ oversized, or unknown frames are dropped. A bad frame can't crash the process: a
207
+ Rust panic is caught at the FFI boundary and re-raised as a Ruby exception. And
208
+ no single client can relay garbage that breaks the others in a room.
209
+
210
+ #### Delivery guarantees
211
+
212
+ The contract is the same at every scale — one process, or hundreds across many
213
+ servers:
214
+
215
+ - **The document always converges.** CRDT updates are commutative and
216
+ idempotent, so out-of-order, duplicate, or concurrent delivery all converge to
217
+ the same correct document. This needs no coordination and holds everywhere.
218
+ - **The durable log never goes gappy.** An update is recorded only once its
219
+ causal dependencies are already in the store (checked against `on_load`); a
220
+ causally-incomplete update triggers a resync instead, so the log always
221
+ rebuilds cleanly.
222
+ - **`on_change` is at-least-once, and the durable guarantee is that replaying the
223
+ log reconstructs the document.** Every update triggers `on_change` before it's acked or
224
+ broadcast (record-before-distribute). If exactly-once updates matter for you, **you
225
+ must make `on_change` idempotent**. But remember that the CRDT can handle duplicates.
226
+ - **A raising `on_change` rejects the update implicitly.** If the block raises,
227
+ the update is neither acked nor broadcast (record-before-distribute stops both).
228
+ There is no negative-ack: the client simply never receives the ack, keeps the
229
+ update pending, and retransmits on its timer/reconnect. This is built for
230
+ *transient* failures (the store is briefly down → a retry lands). A block that
231
+ raises *deterministically* — a validation that always fails for this edit —
232
+ will be retried forever, since nothing tells the client to stop. Enforce hard
233
+ rejections before the edit reaches `on_change` (channel authorization in
234
+ `subscribed`), not by raising inside it.
235
+ - **An over-cap frame is dropped the same silent way.** A frame larger than
236
+ `max_frame_bytes` (default 8 MiB) is dropped before decoding — no ack, no
237
+ broadcast — to bound the work a client can force. For a genuine document
238
+ update that means the same implicit rejection as above: unacked, retransmitted
239
+ forever. Normal typing never approaches the cap, but a large paste, an embedded
240
+ image, or a big initial `SyncStep2` can. The drop is logged (`warn` for
241
+ over-cap, `debug` for undecodable) with the document key and update id so it's
242
+ findable; override `sync_log_context` on the channel to add a user/connection
243
+ id. Size the cap for your largest expected payload, and reject
244
+ genuinely-too-big content upstream rather than relying on the cap to reject it
245
+ gracefully.
246
+
247
+ #### Multi-process deployments
248
+
249
+ Most Rails apps run several processes, and any of them might serve a given document.
250
+ Two pieces keep them in step.
251
+
252
+ Broadcasts cross processes through the Action Cable adapter, so it needs to something
253
+ like `redis` or `solid_cable`, not `async`. With that in place, a change
254
+ on one process reaches clients on all of them.
255
+
256
+ Every process rebuilds document state from the durable store through `on_load`.
257
+ Because changes are recorded before broadcast, record-before-distribute holds
258
+ across processes: whichever process receives a change records it to the shared
259
+ store before anyone, anywhere, sees it.
260
+
261
+ `bun multiprocess.mjs` in the demo runs clients across two processes and checks
262
+ convergence, fresh reads on both, presence across processes, and one shared log.
263
+
264
+ ##### AnyCable
265
+
266
+ `y-ruby` fully supports AnyCable.
267
+
268
+ The demo checks this against a real anycable-go + RPC server
269
+ (`frontend/anycable_probe.mjs`, `anycable_concurrent.mjs`): liveness, the
270
+ y-ruby client provider, cross-process reads, and concurrent convergence.
271
+
272
+ ##### Demo
273
+
274
+ [`examples/actioncable-demo`](examples/actioncable-demo) is a full Rails + Tiptap
275
+ app using the y-ruby provider, with end-to-end tests.
276
+
277
+ #### Record Before Distribute
278
+
279
+ Every document change is handed to the `on_change` handler before broadcasting.
280
+ It is up to you to durably record it:
281
+
282
+ ```ruby
283
+ class DocumentChannel < ApplicationCable::Channel
284
+ include Y::ActionCable::Sync
285
+
286
+ # ...
287
+
288
+ on_change do |key, update|
289
+ # Synchronous, durable write. `update` is the exact CRDT delta.
290
+ AuditLog.append!(key, update) # raise to REJECT the change
291
+ end
292
+
293
+ # ...
294
+ end
295
+ ```
296
+
297
+ If the recorder raises (say the store is down), the change is rejected: not
298
+ applied, not sent to anyone. The cost is a synchronous durable write on the path
299
+ of every change. There's no in-gem per-document lock; concurrent writes to one
300
+ document can both record (at-least-once), and since CRDT apply is idempotent a
301
+ duplicate record replays to the same document.
302
+
303
+ The demo wires `on_change` to a durable Postgres-backed log by default, and checks
304
+ end to end that the log alone rebuilds the document.
305
+
306
+ #### Reliable delivery (acks)
307
+
308
+ y-ruby document delivery is ack-tracked. Browser document updates carry an
309
+ `"id"`, and the server replies `{ "ack": <id> }` once `on_change` has succesfully fired.
310
+ A causally-gapped update is not acked; the server sends a resync request, and
311
+ the client keeps the update queued until it lands.
312
+
313
+ ```
314
+ client -> server { "update": "<base64 update>", "id": 42 }
315
+ server -> client { "ack": 42 } # update accepted; safe to forget
316
+ ```
317
+
318
+ `@y-ruby/client`'s `ActionCableProvider` handles this automatically. It keeps
319
+ the unacknowledged local document tail in a queue and sends the merged tail as a
320
+ single causally-complete delta. The id is the highest sequence in the batch, so
321
+ one `{ ack: id }` cumulatively confirms everything up to it. Because CRDT apply
322
+ is idempotent, a resend that already landed is a harmless no-op that just
323
+ re-acks. Awareness stays ephemeral and is not acked.
324
+
325
+ Presence (cursors, selections) is owned by the browser clients — the server
326
+ never sets or holds presence state, it only relays awareness frames opaquely.
327
+ See `@y-ruby/client` for the client-side awareness API.
328
+
329
+ ## Thread Safety
330
+
331
+ A `Doc` is safe to share across Ruby threads — used concurrently from Puma
332
+ workers, ActionCable connection threads, or background jobs without external
333
+ locking.
334
+
335
+ `test/thread_safety_test.rb` runs shared docs, the full sync handshake, and
336
+ fan-in sync across 8 threads at once, and checks the interleaving doesn't change
337
+ convergence.
338
+
339
+ ### Parallelism (GVL release)
340
+
341
+ Every method that does real CRDT work (applying updates, encoding state,
342
+ handling sync messages) releases Ruby's Global VM Lock
343
+ (`rb_thread_call_without_gvl`) while the native code runs. That buys two things.
344
+
345
+ CRDT work runs in parallel across Ruby threads on MRI, not just
346
+ JRuby/TruffleRuby. `bench/parallelism_bench.rb` measures over 2x wall-clock
347
+ speedup applying a ~900 KB update concurrently; native code that held the GVL
348
+ couldn't beat serial time.
349
+
350
+ A slow operation also can't stall the VM. A thread applying a large update holds
351
+ the doc's write lock without holding the GVL, so other Ruby threads keep running
352
+ instead of queuing behind it.
353
+
354
+ Each method has the same shape: copy Ruby byte strings first, drop the GVL, do
355
+ the yrs work while taking and releasing native locks entirely inside the
356
+ closure, take the GVL back, then build Ruby objects. No Ruby API is touched
357
+ without the GVL, and no native lock is held while reacquiring it, so the lock
358
+ order can't deadlock. Panics in native code are caught and re-raised as Ruby
359
+ exceptions.
360
+
361
+ ## Message Type Constants
362
+
363
+ ```ruby
364
+ Y::MSG_SYNC # 0 - Document sync messages
365
+ Y::MSG_AWARENESS # 1 - User presence data
366
+
367
+ Y::MSG_SYNC_STEP1 # 0 - State vector request
368
+ Y::MSG_SYNC_STEP2 # 1 - Update response
369
+ Y::MSG_SYNC_UPDATE # 2 - Incremental update
370
+ ```
371
+
372
+ ## Sync Flow
373
+
374
+ ```
375
+ Client A Server
376
+ | |
377
+ |-------- connect() ------------->|
378
+ | (SyncStep1 + Awareness) |
379
+ | |
380
+ |<--- handle_sync_message resp ---|
381
+ | (SyncStep2) |
382
+ | |
383
+ | (Document synchronized!) |
384
+ | |
385
+ |<------- updates ----------------|
386
+ |-------- updates --------------->|
387
+ ```
388
+
389
+ ## Development
390
+
391
+ ```bash
392
+ # Setup
393
+ bundle install
394
+
395
+ # Build extension
396
+ rake compile
397
+
398
+ # Run tests
399
+ rake test
400
+
401
+ # Clean build artifacts
402
+ rake clean
403
+ ```
404
+
405
+ ## License
406
+
407
+ MIT License
408
+
409
+ ## Acknowledgments
410
+
411
+ - [y-crdt/yrs](https://github.com/y-crdt/y-crdt) - The Rust implementation of Y.js
412
+ - [Magnus](https://github.com/matsadler/magnus) - Ruby bindings for Rust
413
+ - [rb-sys](https://github.com/oxidize-rb/rb-sys) - Rust extensions for Ruby
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "y"
4
+ require "base64"
5
+
6
+ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
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
+ # { "update" => "<base64 bytes>", "id" => 42 } # client -> server
14
+ # { "update" => "<base64 bytes>" } # server -> subscribers
15
+ # { "ack" => 42 } # server -> sender
16
+ #
17
+ # Example:
18
+ # class DocumentChannel < ApplicationCable::Channel
19
+ # include Y::ActionCable::Sync
20
+ #
21
+ # on_load { |key| Document.find_by(key: key)&.content }
22
+ # # on_change runs in the channel instance's context, so instance methods
23
+ # # (current_user, params, ...) are available:
24
+ # on_change { |key, update| Document.record!(key, update, by: current_user) }
25
+ #
26
+ # def subscribed
27
+ # sync_subscribed params[:id]
28
+ # end
29
+ #
30
+ # def receive(data)
31
+ # sync_receive(data)
32
+ # end
33
+ # end
34
+ #
35
+ # There is no unsubscribe hook: the server keeps no per-connection document or
36
+ # presence state, so a disconnect needs no server-side cleanup.
37
+ #
38
+ # The concern is store-backed and fail-closed: every document update is
39
+ # validated against `on_load`, recorded through `on_change`, then broadcast.
40
+ # No authoritative document state is kept in ActionCable process memory.
41
+ module Sync
42
+ # Frame kinds we act on, from Y.message_kind. Its other codes (0 for a
43
+ # drop: malformed/truncated/multi-message/unknown, and 4 for an awareness
44
+ # query) fall through to a no-op in the dispatch below.
45
+ MSG_KIND_SYNC_STEP1 = 1
46
+ MSG_KIND_UPDATE = 2
47
+ MSG_KIND_AWARENESS = 3
48
+
49
+ # Default incoming-frame size cap (decoded bytes). Generous enough for a
50
+ # large initial SyncStep2, small enough to bound a single message's
51
+ # allocation/parse cost. Override per channel with `max_frame_bytes`.
52
+ DEFAULT_MAX_FRAME_BYTES = 8 * 1024 * 1024
53
+
54
+ def self.included(base)
55
+ base.extend(ClassMethods)
56
+ end
57
+
58
+ module ClassMethods
59
+ # Load persisted document state. Called once per key with (key); return a
60
+ # binary Y.js update (or nil for a fresh document). Runs in the channel
61
+ # instance's context (instance_exec).
62
+ def on_load(&block)
63
+ @on_load = block if block
64
+ return @on_load if defined?(@on_load) && @on_load
65
+
66
+ superclass.respond_to?(:on_load) ? superclass.on_load : nil
67
+ end
68
+
69
+ # Record every document change durably before it is applied or
70
+ # distributed. Called synchronously with (key, update), where update is
71
+ # the exact CRDT delta. If the block raises, the change is rejected:
72
+ # neither acknowledged nor broadcast to other subscribers.
73
+ #
74
+ # Runs in the channel instance's context (instance_exec). Fires from within
75
+ # sync_receive.
76
+ def on_change(&block)
77
+ @on_change = block if block
78
+ return @on_change if defined?(@on_change) && @on_change
79
+
80
+ superclass.respond_to?(:on_change) ? superclass.on_change : nil
81
+ end
82
+
83
+ # Maximum size, in decoded bytes, of an incoming document/awareness frame.
84
+ # Oversized frames are dropped before base64 decode and before native
85
+ # parsing, so a client can't force huge allocations/CPU (a DoS vector).
86
+ # Defaults to DEFAULT_MAX_FRAME_BYTES; set to nil to disable the cap.
87
+ def max_frame_bytes(bytes = :__unset__)
88
+ # Combined reader/writer; the sentinel keeps nil a real value (disables the cap).
89
+ @max_frame_bytes = bytes unless bytes == :__unset__
90
+ return @max_frame_bytes if defined?(@max_frame_bytes)
91
+
92
+ superclass.respond_to?(:max_frame_bytes) ? superclass.max_frame_bytes : DEFAULT_MAX_FRAME_BYTES
93
+ end
94
+ end
95
+
96
+ # Call from `subscribed`. Streams broadcasts for this document and
97
+ # transmits the server's opening handshake (SyncStep1 from the store).
98
+ def sync_subscribed(key)
99
+ @sync_key = key.to_s
100
+ sync_validate_required_hooks!
101
+
102
+ # The document stream is never whisper-enabled; under AnyCable we also
103
+ # subscribe an awareness stream with `whisper: true`, scoping the client-to-
104
+ # client path to ephemeral presence rather than the durable document stream.
105
+ stream_from sync_stream_name
106
+ stream_from sync_awareness_stream_name, whisper: true if respond_to?(:whispers_to)
107
+ sync_transmit(sync_load_doc.sync_step1)
108
+ end
109
+
110
+ # Call from `receive`. Applies the client's message, replies directly
111
+ # when the protocol calls for it, and relays document/awareness changes
112
+ # to the other subscribers.
113
+ #
114
+ # Reliable delivery: document updates carry an "id", and the server replies
115
+ # `{ "ack" => id }` once the update has been durably recorded. A
116
+ # causally-gapped update is not acked; it gets a resync instead, so the
117
+ # client retransmits until the update lands.
118
+ def sync_receive(data, key = nil)
119
+ # Pass `key` (params[:id]) when your transport doesn't keep the channel
120
+ # instance alive across actions. Under AnyCable each RPC command gets a
121
+ # fresh channel, so instance variables set in `subscribed` are gone here.
122
+ @sync_key = key.to_s if key
123
+
124
+ encoded = data.is_a?(Hash) ? data["update"] : nil
125
+ return unless encoded.is_a?(String)
126
+
127
+ # Optional client-supplied id for reliable delivery (see sync_send_ack).
128
+ # data is known to be a Hash here (encoded came from it above).
129
+ id = data["id"]
130
+
131
+ # Frame-size cap: drop oversized frames before decoding (the encoded form
132
+ # is ~4/3 the decoded size) and again after, so a client can't force large
133
+ # base64 decodes / native parses / merges. A dropped frame is never acked,
134
+ # and there is no protocol NACK, so a legitimate oversized update is
135
+ # retransmitted indefinitely. Log the drop so it is at least findable.
136
+ cap = self.class.max_frame_bytes
137
+ if cap && encoded.bytesize > (cap * 4 / 3) + 4
138
+ sync_log_drop(:warn, "encoded #{encoded.bytesize}B exceeds max_frame_bytes #{cap}B", id)
139
+ return
140
+ end
141
+
142
+ begin
143
+ bytes = Base64.strict_decode64(encoded)
144
+ rescue ArgumentError
145
+ sync_log_drop(:debug, "not valid base64", id) # garbage or a probe, rarely a real client
146
+ return # ignore the frame and keep the connection
147
+ end
148
+
149
+ if cap && bytes.bytesize > cap
150
+ sync_log_drop(:warn, "decoded #{bytes.bytesize}B exceeds max_frame_bytes #{cap}B", id)
151
+ return
152
+ end
153
+
154
+ sync_send_ack(id, sync_handle_frame(encoded, bytes))
155
+ end
156
+
157
+ private
158
+
159
+ # Ask this connection's client to resync: re-send SyncStep1 carrying the
160
+ # server's current (gap-free) state vector. The client replies SyncStep2
161
+ # with everything the server is missing, delivered as one causally-complete
162
+ # delta, which heals the gap that triggered the resync.
163
+ def sync_request_resync(doc)
164
+ sync_transmit(doc.sync_step1)
165
+ end
166
+
167
+ # Reliable delivery: acknowledge an accepted update back to the sending
168
+ # connection. An ack-aware client tags each outgoing update with an "id"
169
+ # and retains it until the matching `{ "ack" => id }` returns, retransmitting
170
+ # on a timer or reconnect; idempotent CRDT apply makes resends free. Acks
171
+ # are sent only after the update has been durably recorded, or when a retry
172
+ # is already present in the durable store.
173
+ def sync_send_ack(id, outcome)
174
+ return if id.nil?
175
+ return unless %i[recorded applied].include?(outcome)
176
+
177
+ # The braces are required: a bare hash would bind to transmit's `via:`
178
+ # keyword instead of its positional data argument.
179
+ transmit({ "ack" => id })
180
+ end
181
+
182
+ # Single broadcast point so relay semantics live in one place and tests can
183
+ # observe distribution. Store-backed streams intentionally echo to the
184
+ # sender; applying the same CRDT update twice is a no-op.
185
+ def sync_distribute(encoded)
186
+ ActionCable.server.broadcast(sync_stream_name, sync_envelope(encoded))
187
+ end
188
+
189
+ # Transmit raw protocol bytes to this connection.
190
+ def sync_transmit(bytes)
191
+ transmit(sync_envelope(Base64.strict_encode64(bytes)))
192
+ end
193
+
194
+ def sync_envelope(encoded)
195
+ { "update" => encoded }
196
+ end
197
+
198
+ # Override in the channel to add identifying context to dropped-frame logs --
199
+ # a user id, a connection id, a request id. Return a short string (or nil for
200
+ # none); it is appended to the log line. Default: no extra context.
201
+ def sync_log_context
202
+ nil
203
+ end
204
+
205
+ # Surface a dropped frame through the channel logger. Drops are otherwise
206
+ # invisible (no ack, no broadcast); an oversized legitimate update is never
207
+ # acked and the client retransmits it forever, so make it findable. Names the
208
+ # document key, the reliable-delivery id when present, and whatever
209
+ # sync_log_context returns, so a drop can be tied to a specific document,
210
+ # update, and connection.
211
+ def sync_log_drop(level, reason, id = nil)
212
+ logger.public_send(level) do
213
+ parts = ["key=#{@sync_key.inspect}"]
214
+ parts << "id=#{id}" unless id.nil?
215
+ # A broken context hook must surface, not take down frame handling.
216
+ context = begin
217
+ sync_log_context
218
+ rescue StandardError => e
219
+ "log-context-error=#{e.class}"
220
+ end
221
+ parts << context if context
222
+ "[y-ruby] dropped frame (#{parts.join(" ")}): #{reason}"
223
+ end
224
+ end
225
+
226
+ # This concern acks updates as durably recorded, so it must have both a
227
+ # loader (to rebuild the doc and detect causal gaps) and a recorder (to
228
+ # actually persist before acking). Fail closed rather than silently acking
229
+ # and broadcasting updates that were never stored, which a cold load or
230
+ # reconnect would then lose.
231
+ def sync_validate_required_hooks!
232
+ missing = []
233
+ missing << :on_load unless self.class.on_load
234
+ missing << :on_change unless self.class.on_change
235
+ return if missing.empty?
236
+
237
+ raise Y::Error,
238
+ "Y::ActionCable::Sync requires #{missing.join(" and ")}. Updates are acked as " \
239
+ "durably recorded; without a loader and recorder, an ack would claim a persistence " \
240
+ "that never happened, and a cold load would lose the edit."
241
+ end
242
+
243
+ # Stateless per message: any process can handle any document. A client's
244
+ # SyncStep1 is answered from the store, document changes are recorded durably
245
+ # before relay and then broadcast, and awareness is relayed best-effort.
246
+ # Echoing back to the sender is harmless, since the CRDT apply is idempotent.
247
+ #
248
+ # Returns an outcome symbol for the reliable-delivery ack: :recorded when a
249
+ # document update was durably recorded and relayed, :gap when it was
250
+ # rejected for a resync, :noop for everything else.
251
+ def sync_handle_frame(encoded, bytes)
252
+ sync_validate_required_hooks!
253
+
254
+ case Y.message_kind(bytes)
255
+ when MSG_KIND_SYNC_STEP1
256
+ result = sync_load_doc.handle_sync_message(bytes)
257
+ sync_transmit(result[2])
258
+ :noop
259
+ when MSG_KIND_UPDATE
260
+ update = Y.update_from_message(bytes)
261
+ return :noop unless update
262
+
263
+ # Rebuild from the store (O(history) per update; snapshot in on_load if
264
+ # that cost bites).
265
+ doc = sync_load_doc
266
+
267
+ # Don't record a causally-incomplete update; resync instead so the gap
268
+ # heals as one complete delta.
269
+ unless doc.update_ready?(update)
270
+ sync_request_resync(doc)
271
+ return :gap
272
+ end
273
+
274
+ # Skip a lost-ack retry the store already has. Best-effort, not
275
+ # cross-process exactly-once (see "Delivery guarantees" in the README).
276
+ return :applied unless doc.update_advances?(update)
277
+
278
+ sync_record_change(update) # record before relay
279
+ sync_distribute(encoded)
280
+ :recorded
281
+ when MSG_KIND_AWARENESS
282
+ sync_distribute(encoded)
283
+ :noop
284
+ else
285
+ :noop
286
+ end
287
+ end
288
+
289
+ # Build a fresh document from the durable store (on_load). Callers validate
290
+ # the hooks first, so on_load is present; a nil state means a fresh document.
291
+ def sync_load_doc
292
+ doc = Y::Doc.new
293
+ state = instance_exec(@sync_key, &self.class.on_load)
294
+ doc.apply_update(state) if state
295
+ doc
296
+ end
297
+
298
+ def sync_stream_name
299
+ "y_ruby:#{@sync_key}"
300
+ end
301
+
302
+ def sync_awareness_stream_name
303
+ "#{sync_stream_name}:awareness"
304
+ end
305
+
306
+ # Invoke the on_change recorder in this channel instance's context
307
+ # (instance_exec) so it can reach the channel's own methods. Mirrors how
308
+ # sync_load_doc fetches and runs on_load.
309
+ def sync_record_change(update)
310
+ instance_exec(@sync_key, update, &self.class.on_change)
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module ActionCable
5
+ VERSION = "0.2.0"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "y"
4
+ require "y/action_cable/version"
5
+
6
+ module Y
7
+ # ActionCable integration for y-ruby.
8
+ #
9
+ # Provides Y::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 `y-ruby` gem.
14
+ module ActionCable
15
+ end
16
+ end
17
+
18
+ require "y/action_cable/sync"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `Bundler.require` loads it automatically.
4
+ require "y/action_cable"
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: y-ruby-actioncable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
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: y-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.2.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.2.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: actioncable
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activesupport
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '5.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '5.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ description: 'y-ruby-actioncable adds a Rails ActionCable channel concern (Y::ActionCable::Sync)
97
+ on top of the y-ruby y-crdt bindings: the full y-websocket sync protocol, awareness/presence,
98
+ record-before-distribute auditing, and memory/store backends (AnyCable-ready), so
99
+ a Rails app can be the collaboration server for Y.js editors with no Node sidecar.'
100
+ email:
101
+ - johnpcamara@gmail.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - CHANGELOG-actioncable.md
107
+ - LICENSE
108
+ - README.md
109
+ - lib/y-ruby-actioncable.rb
110
+ - lib/y/action_cable.rb
111
+ - lib/y/action_cable/sync.rb
112
+ - lib/y/action_cable/version.rb
113
+ homepage: https://github.com/jpcamara/y-ruby
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ source_code_uri: https://github.com/jpcamara/y-ruby
118
+ changelog_uri: https://github.com/jpcamara/y-ruby/blob/main/CHANGELOG-actioncable.md
119
+ bug_tracker_uri: https://github.com/jpcamara/y-ruby/issues
120
+ rubygems_mfa_required: 'true'
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: 3.4.0
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 3.6.9
136
+ specification_version: 4
137
+ summary: 'ActionCable integration for y-ruby: the y-websocket sync protocol and awareness
138
+ over ActionCable/AnyCable'
139
+ test_files: []