yrb-lite-actioncable 0.1.0.beta5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d48432476f4a644c91c193f72d6857aa5ce6bb0f2ded537b3277d31e8c49cfb
4
- data.tar.gz: e28d77164e95bbba949133989f11555015160f39786a0c17bab9acacaaf05570
3
+ metadata.gz: 927dca0304b576a035cbbeff9f5e4c72a9c947c01e4a8de584462842b4412d13
4
+ data.tar.gz: 6c7cdde2224501295ee6238562876cf1bd7caea452ac8bac26a8376387cc5e26
5
5
  SHA512:
6
- metadata.gz: d69b40149fccf8d84d8fc4cbfffe7f9ce4309a47e9971bbb0c65befc9bed6a5827d2251caa807da6da5b2b5b7f3ef13c4c8659d2856868848df13748deed77d2
7
- data.tar.gz: a8babf32c1f72b70ff9f3e551c826b62306dd446ee86d44d09b71b5f5164b4c0817328a5ad14e16bcbc0134f1fc4a20624d59abc13a75766292d1a567933e432
6
+ metadata.gz: f3d2b8170e6749403cc9bfb9f84734fc1346fc4200192bd0a35d6e119004aee5d040e5961f2990ac247e1a33665803df532e36a717b20ed6c8be282dd94e2731
7
+ data.tar.gz: c811d7a6ad403ab3465d0d4469751c7632fb5012b950e0854445c2b2f3238f7d582f93456f919de844d3630dd3e96cd8a5eb2ddd699df23273a2b0848fe66992
data/README.md CHANGED
@@ -11,9 +11,8 @@ documents.
11
11
  class DocumentChannel < ApplicationCable::Channel
12
12
  include YrbLite::ActionCable::Sync
13
13
 
14
- def subscribed = sync_for(params[:id])
14
+ def subscribed = sync_subscribed(params[:id])
15
15
  def receive(data) = sync_receive(data)
16
- def unsubscribed = sync_unsubscribed(params[:id])
17
16
  end
18
17
  ```
19
18
 
@@ -35,6 +34,22 @@ What it doesn't do: auth, read-only connections, rate limiting, webhooks,
35
34
  metrics. Hocuspocus ships extensions for those; here you'd build them with
36
35
  Rails.
37
36
 
37
+ ## Why "lite"
38
+
39
+ The "lite" is the size of the surface. yrb-lite binds just the part of y-crdt you
40
+ need to *sync and persist* collaborative documents — a `Doc`, awareness, and the
41
+ y-websocket protocol primitives. The Ruby side treats a document as opaque CRDT
42
+ state: it applies updates, answers sync handshakes, and records deltas, but never
43
+ reaches in to read or edit the contents. The browser editor owns the document's
44
+ shape; Rails owns durability and delivery.
45
+
46
+ A full y-crdt Ruby binding like `y-rb` gives you the whole type system — shared
47
+ text, arrays, maps, XML — to build and query documents in Ruby. yrb-lite leaves
48
+ that out on purpose. What's left is a sync engine plus a one-include ActionCable
49
+ concern, with the server concerns it skips (auth, rate limiting, metrics — see
50
+ above) built from the Rails you already run, and no Node process hosting the
51
+ documents.
52
+
38
53
  ## Testing
39
54
 
40
55
  Ruby and Rust unit tests cover the core. CI also runs the npm client tests and a
@@ -130,16 +145,12 @@ class DocumentChannel < ApplicationCable::Channel
130
145
  on_change { |key, update| MyStore.append(key, update) } # durable record
131
146
 
132
147
  def subscribed
133
- sync_for params[:id]
148
+ sync_subscribed params[:id]
134
149
  end
135
150
 
136
151
  def receive(data)
137
152
  sync_receive(data, params[:id])
138
153
  end
139
-
140
- def unsubscribed
141
- sync_unsubscribed(params[:id])
142
- end
143
154
  end
144
155
  ```
145
156
 
@@ -182,6 +193,26 @@ servers:
182
193
  idempotent** if duplicate side effects would matter (a webhook, a counter) — a
183
194
  raw append-only delta log is naturally fine, since it replays to the same
184
195
  document either way.
196
+ - **A raising `on_change` rejects the update implicitly.** If the block raises,
197
+ the update is neither acked nor broadcast (record-before-distribute stops both).
198
+ There is no negative-ack: the client simply never receives the ack, keeps the
199
+ update pending, and retransmits on its timer/reconnect. This is built for
200
+ *transient* failures (the store is briefly down → a retry lands). A block that
201
+ raises *deterministically* — a validation that always fails for this edit —
202
+ will be retried forever, since nothing tells the client to stop. Enforce hard
203
+ rejections before the edit reaches `on_change` (channel authorization in
204
+ `subscribed`), not by raising inside it.
205
+ - **An over-cap frame is dropped the same silent way.** A frame larger than
206
+ `max_frame_bytes` (default 8 MiB) is dropped before decoding — no ack, no
207
+ broadcast — to bound the work a client can force. For a genuine document
208
+ update that means the same implicit rejection as above: unacked, retransmitted
209
+ forever. Normal typing never approaches the cap, but a large paste, an embedded
210
+ image, or a big initial `SyncStep2` can. The drop is logged (`warn` for
211
+ over-cap, `debug` for undecodable) with the document key and update id so it's
212
+ findable; override `sync_log_context` on the channel to add a user/connection
213
+ id. Size the cap for your largest expected payload, and reject
214
+ genuinely-too-big content upstream rather than relying on the cap to reject it
215
+ gracefully.
185
216
 
186
217
  There is deliberately no in-gem cross-process lock. One that only spanned a
187
218
  single process would give exactly-once at small scale and silently degrade as
@@ -215,9 +246,8 @@ class DocumentChannel < ApplicationCable::Channel
215
246
  on_load { |key| MyStore.load(key) } # required: source of truth
216
247
  on_change { |key, update| MyStore.append(key, update) } # required: record
217
248
 
218
- def subscribed = sync_for(params[:id])
249
+ def subscribed = sync_subscribed(params[:id])
219
250
  def receive(data) = sync_receive(data, params[:id]) # pass the key each call
220
- def unsubscribed = sync_unsubscribed(params[:id])
221
251
  end
222
252
  ```
223
253
 
@@ -229,8 +259,8 @@ end
229
259
  separate awareness stream with AnyCable `whisper: true`, so cursor traffic can
230
260
  take the low-latency client-to-client path without bypassing document
231
261
  durability.
232
- - Pass `params[:id]` into `sync_receive`/`sync_unsubscribed` so the document key
233
- survives AnyCable's per-command instances.
262
+ - Pass `params[:id]` into `sync_receive` so the document key survives AnyCable's
263
+ per-command instances.
234
264
  - The sender gets its own updates echoed back (no Ruby callback to filter them).
235
265
  That's a no-op, since applying an update twice does nothing.
236
266
 
@@ -267,9 +297,8 @@ class DocumentChannel < ApplicationCable::Channel
267
297
  AuditLog.append!(key, update) # raise to REJECT the change
268
298
  end
269
299
 
270
- def subscribed = sync_for(params[:id])
300
+ def subscribed = sync_subscribed(params[:id])
271
301
  def receive(data) = sync_receive(data, params[:id])
272
- def unsubscribed = sync_unsubscribed(params[:id])
273
302
  end
274
303
  ```
275
304
 
@@ -362,8 +391,6 @@ exceptions.
362
391
  ```ruby
363
392
  YrbLite::MSG_SYNC # 0 - Document sync messages
364
393
  YrbLite::MSG_AWARENESS # 1 - User presence data
365
- YrbLite::MSG_AUTH # 2 - Authentication
366
- YrbLite::MSG_QUERY_AWARENESS # 3 - Request awareness state
367
394
 
368
395
  YrbLite::MSG_SYNC_STEP1 # 0 - State vector request
369
396
  YrbLite::MSG_SYNC_STEP2 # 1 - Update response
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Entry point matching the gem name, so `Bundler.require` works out of the box.
3
+ # Entry point matching the gem name, so `Bundler.require` loads it automatically.
4
4
  require "yrb_lite/action_cable"
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "yrb_lite"
4
4
  require "base64"
5
- require "securerandom"
6
5
 
7
6
  module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
8
7
  # y-websocket protocol over ActionCable.
@@ -12,7 +11,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
12
11
  # y-protocols binary messages, base64-encoded in a JSON envelope:
13
12
  #
14
13
  # { "update" => "<base64 bytes>", "id" => 42 } # client -> server
15
- # { "update" => "...", "origin" => "<id>" } # server -> subscribers
14
+ # { "update" => "<base64 bytes>" } # server -> subscribers
16
15
  # { "ack" => 42 } # server -> sender
17
16
  #
18
17
  # Example:
@@ -20,30 +19,29 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
20
19
  # include YrbLite::ActionCable::Sync
21
20
  #
22
21
  # on_load { |key| Document.find_by(key: key)&.content }
23
- # # on_change blocks run in the channel instance's context, so instance
24
- # # methods (current_user, params, ...) are available without plumbing:
22
+ # # on_change runs in the channel instance's context, so instance methods
23
+ # # (current_user, params, ...) are available:
25
24
  # on_change { |key, update| Document.record!(key, update, by: current_user) }
26
25
  #
27
26
  # def subscribed
28
- # sync_for params[:id]
27
+ # sync_subscribed params[:id]
29
28
  # end
30
29
  #
31
30
  # def receive(data)
32
31
  # sync_receive(data)
33
32
  # end
34
- #
35
- # def unsubscribed
36
- # sync_unsubscribed
37
- # end
38
33
  # end
39
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
+ #
40
38
  # The concern is store-backed and fail-closed: every document update is
41
39
  # validated against `on_load`, recorded through `on_change`, then broadcast.
42
40
  # No authoritative document state is kept in ActionCable process memory.
43
41
  module Sync
44
- # Frame kinds we act on, from Awareness#message_kind. The other codes it can
45
- # return -- 0 (drop: malformed/truncated/multi-message/unknown) and 4
46
- # (awareness query) -- fall through to a no-op in the dispatch below.
42
+ # Frame kinds we act on, from YrbLite.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.
47
45
  MSG_KIND_SYNC_STEP1 = 1
48
46
  MSG_KIND_UPDATE = 2
49
47
  MSG_KIND_AWARENESS = 3
@@ -59,8 +57,8 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
59
57
 
60
58
  module ClassMethods
61
59
  # Load persisted document state. Called once per key with (key); return a
62
- # binary Y.js update (or nil for a fresh document). The block runs in the
63
- # channel instance's context, the same as on_change (see below).
60
+ # binary Y.js update (or nil for a fresh document). Runs in the channel
61
+ # instance's context (instance_exec).
64
62
  def on_load(&block)
65
63
  @on_load = block if block
66
64
  return @on_load if defined?(@on_load) && @on_load
@@ -73,10 +71,8 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
73
71
  # the exact CRDT delta. If the block raises, the change is rejected:
74
72
  # neither acknowledged nor broadcast to other subscribers.
75
73
  #
76
- # The block runs in the *channel instance's* context (via instance_exec),
77
- # so it can call the channel's own methods (current_user, params, a
78
- # per-connection Current.* accessor) directly, with no thread-local
79
- # plumbing. on_change always fires from within sync_receive.
74
+ # Runs in the channel instance's context (instance_exec). Fires from within
75
+ # sync_receive.
80
76
  def on_change(&block)
81
77
  @on_change = block if block
82
78
  return @on_change if defined?(@on_change) && @on_change
@@ -89,6 +85,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
89
85
  # parsing, so a client can't force huge allocations/CPU (a DoS vector).
90
86
  # Defaults to DEFAULT_MAX_FRAME_BYTES; set to nil to disable the cap.
91
87
  def max_frame_bytes(bytes = :__unset__)
88
+ # Combined reader/writer; the sentinel keeps nil a real value (disables the cap).
92
89
  @max_frame_bytes = bytes unless bytes == :__unset__
93
90
  return @max_frame_bytes if defined?(@max_frame_bytes)
94
91
 
@@ -98,10 +95,9 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
98
95
 
99
96
  # Call from `subscribed`. Streams broadcasts for this document and
100
97
  # transmits the server's opening handshake (SyncStep1 from the store).
101
- def sync_for(key)
98
+ def sync_subscribed(key)
102
99
  @sync_key = key.to_s
103
- @sync_origin = SecureRandom.hex(8)
104
- sync_require_store_recorder!
100
+ sync_validate_required_hooks!
105
101
 
106
102
  # The document stream is never whisper-enabled; under AnyCable we also
107
103
  # subscribe an awareness stream with `whisper: true`, scoping the client-to-
@@ -117,7 +113,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
117
113
  #
118
114
  # Reliable delivery: document updates carry an "id", and the server replies
119
115
  # `{ "ack" => id }` once the update has been durably recorded. A
120
- # causally-gapped update is not acked -- it gets a resync instead -- so the
116
+ # causally-gapped update is not acked; it gets a resync instead, so the
121
117
  # client retransmits until the update lands.
122
118
  def sync_receive(data, key = nil)
123
119
  # Pass `key` (params[:id]) when your transport doesn't keep the channel
@@ -129,37 +125,41 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
129
125
  return unless encoded.is_a?(String)
130
126
 
131
127
  # Optional client-supplied id for reliable delivery (see sync_send_ack).
132
- id = data.is_a?(Hash) ? data["id"] : nil
128
+ # data is known to be a Hash here (encoded came from it above).
129
+ id = data["id"]
133
130
 
134
131
  # Frame-size cap: drop oversized frames before decoding (the encoded form
135
132
  # is ~4/3 the decoded size) and again after, so a client can't force large
136
- # base64 decodes / native parses / merges. A dropped frame is never acked.
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.
137
136
  cap = self.class.max_frame_bytes
138
- return if cap && encoded.bytesize > (cap * 4 / 3) + 4
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
139
141
 
140
142
  begin
141
143
  bytes = Base64.strict_decode64(encoded)
142
144
  rescue ArgumentError
143
- return # not valid base64; ignore the frame and keep the connection
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
144
147
  end
145
148
 
146
- return if cap && bytes.bytesize > cap
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
147
153
 
148
154
  sync_send_ack(id, sync_handle_frame(encoded, bytes))
149
155
  end
150
156
 
151
- # The `unsubscribed` hook target. Nothing to clean up: the server keeps no
152
- # per-connection document or presence state.
153
- def sync_unsubscribed(key = nil)
154
- @sync_key = key.to_s if key
155
- end
156
-
157
157
  private
158
158
 
159
159
  # Ask this connection's client to resync: re-send SyncStep1 carrying the
160
160
  # server's current (gap-free) state vector. The client replies SyncStep2
161
161
  # with everything the server is missing, delivered as one causally-complete
162
- # delta -- which heals the gap that triggered the resync.
162
+ # delta, which heals the gap that triggered the resync.
163
163
  def sync_request_resync(doc)
164
164
  sync_transmit(doc.sync_step1)
165
165
  end
@@ -174,7 +174,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
174
174
  return if id.nil?
175
175
  return unless %i[recorded applied].include?(outcome)
176
176
 
177
- # Braces are load-bearing: a bare hash would bind to transmit's `via:`
177
+ # The braces are required: a bare hash would bind to transmit's `via:`
178
178
  # keyword instead of its positional data argument.
179
179
  transmit({ "ack" => id })
180
180
  end
@@ -183,10 +183,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
183
183
  # observe distribution. Store-backed streams intentionally echo to the
184
184
  # sender; applying the same CRDT update twice is a no-op.
185
185
  def sync_distribute(encoded)
186
- ActionCable.server.broadcast(
187
- sync_stream_name,
188
- sync_envelope(encoded, "origin" => @sync_origin, "pid" => Sync.process_id)
189
- )
186
+ ActionCable.server.broadcast(sync_stream_name, sync_envelope(encoded))
190
187
  end
191
188
 
192
189
  # Transmit raw protocol bytes to this connection.
@@ -194,16 +191,44 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
194
191
  transmit(sync_envelope(Base64.strict_encode64(bytes)))
195
192
  end
196
193
 
197
- def sync_envelope(encoded, extra = {})
198
- { "update" => encoded }.merge(extra)
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
+ "[yrb-lite] dropped frame (#{parts.join(" ")}): #{reason}"
223
+ end
199
224
  end
200
225
 
201
- # This concern acks updates as *durably recorded*, so it MUST have both a
226
+ # This concern acks updates as durably recorded, so it must have both a
202
227
  # loader (to rebuild the doc and detect causal gaps) and a recorder (to
203
228
  # actually persist before acking). Fail closed rather than silently acking
204
- # and broadcasting updates that were never stored -- which a cold load or
229
+ # and broadcasting updates that were never stored, which a cold load or
205
230
  # reconnect would then lose.
206
- def sync_require_store_recorder!
231
+ def sync_validate_required_hooks!
207
232
  missing = []
208
233
  missing << :on_load unless self.class.on_load
209
234
  missing << :on_change unless self.class.on_change
@@ -224,12 +249,12 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
224
249
  # document update was durably recorded and relayed, :gap when it was
225
250
  # rejected for a resync, :noop for everything else.
226
251
  def sync_handle_frame(encoded, bytes)
227
- sync_require_store_recorder!
252
+ sync_validate_required_hooks!
228
253
 
229
254
  case YrbLite.message_kind(bytes)
230
255
  when MSG_KIND_SYNC_STEP1
231
256
  result = sync_load_doc.handle_sync_message(bytes)
232
- sync_transmit(result[2]) if result
257
+ sync_transmit(result[2])
233
258
  :noop
234
259
  when MSG_KIND_UPDATE
235
260
  update = YrbLite.update_from_message(bytes)
@@ -250,7 +275,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
250
275
  # cross-process exactly-once (see "Delivery guarantees" in the README).
251
276
  return :applied unless doc.update_advances?(update)
252
277
 
253
- sync_record_change(self.class.on_change, update) # record before relay
278
+ sync_record_change(update) # record before relay
254
279
  sync_distribute(encoded)
255
280
  :recorded
256
281
  when MSG_KIND_AWARENESS
@@ -261,11 +286,11 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
261
286
  end
262
287
  end
263
288
 
264
- # Build a fresh document from the durable store (on_load).
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.
265
291
  def sync_load_doc
266
292
  doc = YrbLite::Doc.new
267
- loader = self.class.on_load
268
- state = instance_exec(@sync_key, &loader) if loader
293
+ state = instance_exec(@sync_key, &self.class.on_load)
269
294
  doc.apply_update(state) if state
270
295
  doc
271
296
  end
@@ -279,20 +304,10 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
279
304
  end
280
305
 
281
306
  # Invoke the on_change recorder in this channel instance's context
282
- # (instance_exec) so it can reach the channel's own methods.
283
- def sync_record_change(recorder, update)
284
- instance_exec(@sync_key, update, &recorder)
285
- end
286
-
287
- # -- Shared process state ----------------------------------------------
288
-
289
- class << self
290
- # A stable id for this server process, stamped on every broadcast so
291
- # other processes know to apply it to their replica and this process
292
- # knows to skip its own. Survives for the life of the process.
293
- def process_id
294
- @process_id ||= SecureRandom.hex(8)
295
- end
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)
296
311
  end
297
312
  end
298
313
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module YrbLite
4
4
  module ActionCable
5
- VERSION = "0.1.0.beta5"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrb-lite-actioncable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.1.0.beta9
32
+ version: 0.2.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 0.1.0.beta9
39
+ version: 0.2.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: actioncable
42
42
  requirement: !ruby/object:Gem::Requirement