yrb-lite 0.1.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/yrb-lite.rb ADDED
@@ -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"
@@ -0,0 +1,456 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "securerandom"
5
+
6
+ module YrbLite
7
+ # y-websocket protocol over ActionCable.
8
+ #
9
+ # Include this module in an ActionCable channel to sync Y.js documents
10
+ # (and awareness/presence) with browser clients. Messages are the standard
11
+ # y-protocols binary messages, base64-encoded in a JSON envelope:
12
+ #
13
+ # { "m" => "<base64 bytes>" } # client -> server
14
+ # { "m" => "...", "origin" => "<id>" } # server -> subscribers
15
+ #
16
+ # Example:
17
+ # class DocumentChannel < ApplicationCable::Channel
18
+ # include YrbLite::Sync
19
+ #
20
+ # on_load { |key| Document.find_by(key: key)&.content }
21
+ # on_save { |key, update| Document.find_by(key: key)&.update!(content: update) }
22
+ #
23
+ # def subscribed
24
+ # sync_for params[:id]
25
+ # end
26
+ #
27
+ # def receive(data)
28
+ # sync_receive(data)
29
+ # end
30
+ #
31
+ # def unsubscribed
32
+ # sync_clear_presence
33
+ # end
34
+ # end
35
+ #
36
+ # The shared YrbLite::Awareness instances are safe to use from ActionCable's
37
+ # worker thread pool: the native types are Send + Sync and every operation
38
+ # releases the GVL, so concurrent clients sync in parallel.
39
+ module Sync
40
+ # Validated frame kinds from Awareness#message_kind. A frame only gets a
41
+ # non-DROP kind if it is exactly one well-formed message; anything
42
+ # malformed, truncated, multi-message, or unknown is dropped before it can
43
+ # be processed or relayed.
44
+ MSG_KIND_DROP = 0
45
+ MSG_KIND_SYNC_STEP1 = 1
46
+ MSG_KIND_UPDATE = 2
47
+ MSG_KIND_AWARENESS = 3
48
+ MSG_KIND_AWARENESS_QUERY = 4
49
+
50
+ def self.included(base)
51
+ base.extend(ClassMethods)
52
+ end
53
+
54
+ module ClassMethods
55
+ # Load persisted document state. Called once per key with (key);
56
+ # return a binary Y.js update (or nil for a fresh document).
57
+ def on_load(callable = nil, &block)
58
+ @on_load = callable || block if callable || block
59
+ @on_load
60
+ end
61
+
62
+ # Persist document state. Called with (key, update) after every
63
+ # message that modified the document.
64
+ def on_save(callable = nil, &block)
65
+ @on_save = callable || block if callable || block
66
+ @on_save
67
+ end
68
+
69
+ # Record every document change durably before it is applied or
70
+ # distributed (authoritative audit mode). Called synchronously with
71
+ # (key, update), where update is the exact CRDT delta, serialized per
72
+ # document so the recorded order is the apply order. If the block raises,
73
+ # the change is rejected: neither applied to the shared document nor
74
+ # broadcast to other subscribers.
75
+ #
76
+ # Registering an on_change switches that channel onto the strict path
77
+ # (record, apply, broadcast). Without it, the default fast path applies
78
+ # and broadcasts, with an optional on_save snapshot.
79
+ def on_change(callable = nil, &block)
80
+ @on_change = callable || block if callable || block
81
+ @on_change
82
+ end
83
+
84
+ # Select the document backend:
85
+ # :memory (default): keep a warm in-memory replica per process and keep
86
+ # it current via a custom stream_from callback. Fast, but it assumes
87
+ # classic ActionCable (the callback runs in Ruby) and
88
+ # process<->document affinity.
89
+ # :store: stateless per message, with no warm replica and no custom
90
+ # stream callback. Handshakes and reads are served from the durable
91
+ # store (`on_load`); changes are recorded (`on_change`) and relayed.
92
+ # Works under AnyCable (broadcasts handled outside Ruby, no worker
93
+ # affinity) and across processes. Requires `on_load` and `on_change`.
94
+ def sync_backend(mode = nil)
95
+ @sync_backend = mode if mode
96
+ @sync_backend || :memory
97
+ end
98
+ end
99
+
100
+ # Call from `subscribed`. Streams broadcasts for this document and
101
+ # transmits the server's opening handshake (SyncStep1 + awareness).
102
+ def sync_for(key)
103
+ @sync_key = key.to_s
104
+ @sync_origin = SecureRandom.hex(8)
105
+ @sync_clients = [] # awareness client IDs seen on this connection
106
+
107
+ return sync_for_store_backed if self.class.sync_backend == :store
108
+
109
+ Sync.subscribe(@sync_key)
110
+ awareness = sync_awareness
111
+
112
+ stream_from sync_stream_name, coder: ActiveSupport::JSON do |payload|
113
+ sync_on_broadcast(payload)
114
+ end
115
+
116
+ # Opening handshake: SyncStep1 then the current awareness, each as its
117
+ # own single-message frame, so providers that parse one message per frame
118
+ # (e.g. @y-rb/actioncable) handle both. The client replies SyncStep2 to
119
+ # the SyncStep1, delivering its state to the server.
120
+ sync_transmit(awareness.sync_step1)
121
+ sync_transmit(awareness.encode_awareness_update)
122
+ end
123
+
124
+ # Call from `receive`. Applies the client's message, replies directly
125
+ # when the protocol calls for it, and relays document/awareness changes
126
+ # to the other subscribers.
127
+ #
128
+ # If an `on_change` recorder is registered, document changes take the
129
+ # strict authoritative path (record -> apply -> broadcast, serialized per
130
+ # document); otherwise the fast path is used.
131
+ def sync_receive(data, key = nil)
132
+ # Pass `key` (params[:id]) when your transport doesn't keep the channel
133
+ # instance alive across actions. Under AnyCable each RPC command gets a
134
+ # fresh channel, so instance variables set in `subscribed` are gone here.
135
+ @sync_key = key.to_s if key
136
+
137
+ # Accept both envelope keys: "m" (yrb-lite's own clients) and "update"
138
+ # (the @y-rb/actioncable browser provider).
139
+ m = data.is_a?(Hash) ? (data["m"] || data["update"]) : nil
140
+ return unless m.is_a?(String)
141
+
142
+ begin
143
+ bytes = Base64.strict_decode64(m)
144
+ rescue ArgumentError
145
+ return # not valid base64; ignore the frame and keep the connection
146
+ end
147
+
148
+ return sync_receive_store_backed(m, bytes) if self.class.sync_backend == :store
149
+
150
+ awareness = sync_awareness
151
+ kind = awareness.message_kind(bytes)
152
+ # Malformed / truncated / multi-message / unknown frames are dropped
153
+ # before they can be processed or relayed to other clients.
154
+ return if kind == MSG_KIND_DROP
155
+
156
+ sync_track_clients(awareness, bytes) if kind == MSG_KIND_AWARENESS
157
+
158
+ if kind == MSG_KIND_UPDATE && self.class.on_change
159
+ sync_apply_authoritative(awareness, m, bytes)
160
+ else
161
+ sync_apply_fast(awareness, m, bytes, kind)
162
+ end
163
+ end
164
+
165
+ # Call from `unsubscribed`. Clears the presence states this connection
166
+ # introduced and tells the other subscribers to drop those cursors, so a
167
+ # closed tab or dropped socket doesn't leave a ghost cursor behind until
168
+ # the client-side timeout reaps it.
169
+ def sync_clear_presence
170
+ return if @sync_clients.nil? || @sync_clients.empty?
171
+
172
+ removal = sync_awareness.remove_clients(@sync_clients)
173
+ @sync_clients = []
174
+ return if removal.empty?
175
+
176
+ sync_distribute(Base64.strict_encode64(removal))
177
+ end
178
+
179
+ # Call from `unsubscribed`. Clears this connection's presence and, when the
180
+ # last subscriber for the document leaves, persists and unloads it from
181
+ # memory (only when an `on_load` is configured to bring it back; otherwise
182
+ # the in-memory document is the only copy and is kept). Prevents a
183
+ # long-running server from accumulating every document it has ever served.
184
+ def sync_unsubscribed(key = nil)
185
+ @sync_key = key.to_s if key
186
+ return if self.class.sync_backend == :store # nothing cached per process
187
+
188
+ sync_clear_presence
189
+ saver = self.class.on_save
190
+ Sync.release(@sync_key, evictable: !self.class.on_load.nil?) do |awareness|
191
+ saver&.call(@sync_key, awareness.encode_state_as_update)
192
+ end
193
+ end
194
+
195
+ # The shared Awareness (document + presence) for this channel's key.
196
+ # Also useful for server-side reads, e.g.:
197
+ # sync_awareness.encode_state_as_update
198
+ def sync_awareness
199
+ Sync.awareness_for(@sync_key, self.class.on_load)
200
+ end
201
+
202
+ private
203
+
204
+ # Default path: apply the message, answer direct requests, relay
205
+ # state-changing messages to the other subscribers. Routing comes from the
206
+ # native `kind` (from Awareness#message_kind) rather than peeking at bytes.
207
+ # Document changes (SyncStep2, Update) and awareness get relayed; requests
208
+ # (SyncStep1, awareness-query) are answered above and not relayed. An
209
+ # optional on_save snapshot is taken after a document change.
210
+ def sync_apply_fast(awareness, encoded, bytes, kind)
211
+ response = awareness.handle(bytes)
212
+ sync_transmit(response) unless response.empty?
213
+
214
+ return unless [MSG_KIND_UPDATE, MSG_KIND_AWARENESS].include?(kind)
215
+
216
+ sync_distribute(encoded)
217
+ sync_persist if kind == MSG_KIND_UPDATE
218
+ end
219
+
220
+ # Authoritative path: record the change durably, then apply it to the
221
+ # shared document, then distribute it. The sequence runs under a
222
+ # per-document lock so changes are recorded in a single total order that
223
+ # matches the order they're applied, and nothing is distributed (or applied)
224
+ # before it has been recorded. If the recorder raises, the change is
225
+ # rejected (not applied, not broadcast) and the exception propagates, so the
226
+ # channel can surface it and the client can resync.
227
+ def sync_apply_authoritative(awareness, encoded, bytes)
228
+ recorder = self.class.on_change
229
+
230
+ modified = Sync.lock_for(@sync_key).synchronize do
231
+ update = awareness.update_from_message(bytes)
232
+ # A no-op message (e.g. the empty SyncStep2 in a client's opening
233
+ # handshake) carries no change, so there's nothing to record or relay.
234
+ next false unless update
235
+
236
+ recorder.call(@sync_key, update) # durable write; raise to reject
237
+ awareness.apply_update(update) # only recorded changes reach the doc
238
+ sync_distribute(encoded) # ...and only then the wire
239
+ true
240
+ end
241
+
242
+ sync_persist if modified
243
+ end
244
+
245
+ # Single broadcast point for both paths (and presence removal), so the
246
+ # relay semantics live in one place and tests can observe distribution.
247
+ # `origin` identifies the sending connection (don't echo to it); `pid`
248
+ # identifies the sending process (other processes apply it to their own
249
+ # replica; see sync_on_broadcast).
250
+ def sync_distribute(encoded)
251
+ ActionCable.server.broadcast(
252
+ sync_stream_name,
253
+ sync_envelope(encoded, "origin" => @sync_origin, "pid" => Sync.process_id)
254
+ )
255
+ end
256
+
257
+ # Transmit raw protocol bytes to this connection (base64, dual-key).
258
+ def sync_transmit(bytes)
259
+ transmit(sync_envelope(Base64.strict_encode64(bytes)))
260
+ end
261
+
262
+ # Build an outgoing envelope. We send the payload under both keys: "m"
263
+ # (yrb-lite's own clients) and "update" (the @y-rb/actioncable provider),
264
+ # so either client works against the same server.
265
+ def sync_envelope(encoded, extra = {})
266
+ { "m" => encoded, "update" => encoded }.merge(extra)
267
+ end
268
+
269
+ # Handle a broadcast delivered by the cable adapter. With a multi-process
270
+ # adapter (Redis, solid_cable), it may have come from another server
271
+ # process. Keep this process's in-memory replica current with changes that
272
+ # originated elsewhere, then relay to this connection's browser.
273
+ def sync_on_broadcast(payload)
274
+ sync_apply_remote(payload["m"]) if payload["pid"] != Sync.process_id
275
+ transmit(payload) unless payload["origin"] == @sync_origin
276
+ end
277
+
278
+ # Apply a change that originated on another process to this process's
279
+ # replica, without re-recording it (the origin process already recorded it
280
+ # before broadcasting). The CRDT merge is idempotent and commutative, so a
281
+ # cold replica converges regardless of ordering, and applying from several
282
+ # local connections is harmless.
283
+ def sync_apply_remote(encoded)
284
+ return unless encoded.is_a?(String)
285
+
286
+ begin
287
+ bytes = Base64.strict_decode64(encoded)
288
+ rescue ArgumentError
289
+ return
290
+ end
291
+
292
+ awareness = sync_awareness
293
+ case awareness.message_kind(bytes)
294
+ when MSG_KIND_UPDATE
295
+ update = awareness.update_from_message(bytes)
296
+ awareness.apply_update(update) if update
297
+ when MSG_KIND_AWARENESS
298
+ awareness.handle(bytes)
299
+ end
300
+ end
301
+
302
+ # -- Store-backed (AnyCable-native) path --------------------------------
303
+
304
+ # Subscribe without a custom block, so AnyCable (which delivers broadcasts
305
+ # outside Ruby) relays them directly. Send the opening SyncStep1 built from
306
+ # the durable store. No warm replica is kept.
307
+ def sync_for_store_backed
308
+ stream_from sync_stream_name
309
+ sync_transmit(sync_load_doc.sync_step1)
310
+ end
311
+
312
+ # Stateless per message: no warm replica, no assumptions about which process
313
+ # owns a document. A client's SyncStep1 is answered from the store, document
314
+ # changes are recorded durably before relay and then broadcast, and
315
+ # awareness is relayed best-effort. Echoing back to the sender is harmless,
316
+ # since the CRDT apply is idempotent.
317
+ def sync_receive_store_backed(encoded, bytes)
318
+ case Sync.codec.message_kind(bytes)
319
+ when MSG_KIND_SYNC_STEP1
320
+ result = sync_load_doc.handle_sync_message(bytes)
321
+ sync_transmit(result[2]) if result
322
+ when MSG_KIND_UPDATE
323
+ update = Sync.codec.update_from_message(bytes)
324
+ return unless update
325
+
326
+ self.class.on_change&.call(@sync_key, update) # record before relay
327
+ sync_distribute(encoded)
328
+ when MSG_KIND_AWARENESS
329
+ sync_distribute(encoded)
330
+ end
331
+ end
332
+
333
+ # Build a fresh document from the durable store (on_load).
334
+ def sync_load_doc
335
+ doc = YrbLite::Doc.new
336
+ state = self.class.on_load&.call(@sync_key)
337
+ doc.apply_update(state) if state
338
+ doc
339
+ end
340
+
341
+ # Record the awareness client IDs carried by an incoming message (already
342
+ # known to be an awareness frame) so we can clear them when this connection
343
+ # closes.
344
+ def sync_track_clients(awareness, bytes)
345
+ awareness.awareness_client_ids(bytes).each do |id|
346
+ @sync_clients << id unless @sync_clients.include?(id)
347
+ end
348
+ end
349
+
350
+ def sync_stream_name
351
+ "yrb_lite:#{@sync_key}"
352
+ end
353
+
354
+ def sync_persist
355
+ return unless (saver = self.class.on_save)
356
+
357
+ saver.call(@sync_key, sync_awareness.encode_state_as_update)
358
+ end
359
+
360
+ # -- Shared document registry ------------------------------------------
361
+
362
+ @registry = {}
363
+ @locks = {}
364
+ @subscribers = Hash.new(0)
365
+ @registry_mutex = Mutex.new
366
+
367
+ class << self
368
+ # A stable id for this server process, stamped on every broadcast so
369
+ # other processes know to apply it to their replica and this process
370
+ # knows to skip its own. Survives for the life of the process.
371
+ def process_id
372
+ @process_id ||= SecureRandom.hex(8)
373
+ end
374
+
375
+ # A shared, stateless decoder for the store-backed path. message_kind and
376
+ # update_from_message only read their argument (they don't touch the
377
+ # instance's document), so one shared instance is safe across threads.
378
+ def codec
379
+ @codec ||= YrbLite::Awareness.new
380
+ end
381
+
382
+ # Get or create the shared Awareness for a key. Creation (including
383
+ # the on_load callback) is serialized under a mutex so concurrent
384
+ # subscribers can never observe two documents for one key; all
385
+ # subsequent operations run lock-free on the thread-safe native types.
386
+ def awareness_for(key, loader = nil)
387
+ @registry_mutex.synchronize do
388
+ @registry[key] ||= begin
389
+ awareness = YrbLite::Awareness.new
390
+ if loader && (state = loader.call(key))
391
+ awareness.apply_update(state)
392
+ end
393
+ awareness
394
+ end
395
+ end
396
+ end
397
+
398
+ # Per-document mutex serializing the authoritative record -> apply ->
399
+ # broadcast section, so a document's audit log is a single total order.
400
+ # Only briefly holds the registry mutex to fetch/create the lock; the
401
+ # durable write itself runs while holding only this per-key lock.
402
+ def lock_for(key)
403
+ @registry_mutex.synchronize { @locks[key] ||= Mutex.new }
404
+ end
405
+
406
+ # Count a new subscriber for a document.
407
+ def subscribe(key)
408
+ @registry_mutex.synchronize { @subscribers[key] += 1 }
409
+ end
410
+
411
+ # Drop a subscriber. When the last one leaves and the document is
412
+ # evictable (there's an on_load to bring it back, so unloading can't lose
413
+ # data), persist it via the given block and unload it from memory, so a
414
+ # long-running server doesn't accumulate every document and lock it has
415
+ # ever seen. Returns true if the document was evicted.
416
+ #
417
+ # The persist runs outside the registry lock (it may do I/O), and we
418
+ # re-check the subscriber count afterward: if someone reconnected while
419
+ # we were saving, eviction is aborted and the warm document is kept.
420
+ def release(key, evictable:)
421
+ awareness = @registry_mutex.synchronize do
422
+ @subscribers[key] -= 1 if @subscribers[key].positive?
423
+ next nil unless @subscribers[key].zero?
424
+
425
+ @subscribers.delete(key)
426
+ evictable ? @registry[key] : nil
427
+ end
428
+ return false unless awareness
429
+
430
+ yield awareness if block_given?
431
+
432
+ @registry_mutex.synchronize do
433
+ # A subscriber may have returned during the persist above.
434
+ next false unless @subscribers[key].zero?
435
+
436
+ @subscribers.delete(key)
437
+ @locks.delete(key)
438
+ !@registry.delete(key).nil?
439
+ end
440
+ end
441
+
442
+ def registry
443
+ @registry_mutex.synchronize { @registry.dup }
444
+ end
445
+
446
+ # Clear all documents (useful for testing).
447
+ def reset!
448
+ @registry_mutex.synchronize do
449
+ @registry = {}
450
+ @locks = {}
451
+ @subscribers = Hash.new(0)
452
+ end
453
+ end
454
+ end
455
+ end
456
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YrbLite
4
+ VERSION = "0.1.0.beta1"
5
+ end
data/lib/yrb_lite.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "yrb_lite/version"
4
+
5
+ # Load the native extension. Precompiled gems ship it in a per-Ruby-version
6
+ # subdir (lib/yrb_lite/<major.minor>/yrb_lite.<ext>); a source build puts it
7
+ # flat at lib/yrb_lite/yrb_lite.<ext>. Try the versioned path first, fall back.
8
+ begin
9
+ RUBY_VERSION =~ /(\d+\.\d+)/
10
+ require_relative "yrb_lite/#{Regexp.last_match(1)}/yrb_lite"
11
+ rescue LoadError
12
+ require_relative "yrb_lite/yrb_lite"
13
+ end
14
+
15
+ module YrbLite
16
+ # Error class is defined in Rust extension
17
+
18
+ # Autoload Sync module - only loaded when ActionCable is available
19
+ autoload :Sync, "yrb_lite/sync"
20
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yrb-lite
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: rb_sys
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
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
+ - !ruby/object:Gem::Dependency
69
+ name: rake-compiler
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.2'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.2'
82
+ description: yrb-lite is a thread-safe Ruby binding over the Rust y-crdt (yrs) library
83
+ plus an ActionCable concern implementing the full y-websocket sync protocol and
84
+ awareness. It lets a Rails app be the collaboration server for Y.js editors (Tiptap,
85
+ ProseMirror, BlockNote) with no Node sidecar.
86
+ email:
87
+ - johnpcamara@gmail.com
88
+ executables: []
89
+ extensions:
90
+ - ext/yrb_lite/extconf.rb
91
+ extra_rdoc_files: []
92
+ files:
93
+ - CHANGELOG.md
94
+ - Cargo.toml
95
+ - LICENSE
96
+ - README.md
97
+ - ext/yrb_lite/Cargo.toml
98
+ - ext/yrb_lite/extconf.rb
99
+ - ext/yrb_lite/src/lib.rs
100
+ - lib/yrb-lite.rb
101
+ - lib/yrb_lite.rb
102
+ - lib/yrb_lite/sync.rb
103
+ - lib/yrb_lite/version.rb
104
+ homepage: https://github.com/jpcamara/yrb-lite
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ source_code_uri: https://github.com/jpcamara/yrb-lite
109
+ changelog_uri: https://github.com/jpcamara/yrb-lite/blob/main/CHANGELOG.md
110
+ bug_tracker_uri: https://github.com/jpcamara/yrb-lite/issues
111
+ rubygems_mfa_required: 'true'
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: 3.4.0
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.6.9
127
+ specification_version: 4
128
+ summary: Thread-safe Ruby bindings for y-crdt (Y.js) with the y-websocket sync protocol
129
+ for ActionCable
130
+ test_files: []