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 +7 -0
- data/CHANGELOG-actioncable.md +23 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/lib/y/action_cable/sync.rb +313 -0
- data/lib/y/action_cable/version.rb +7 -0
- data/lib/y/action_cable.rb +18 -0
- data/lib/y-ruby-actioncable.rb +4 -0
- metadata +139 -0
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
|
+
[](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,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"
|
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: []
|