y-rb_actioncable 0.1.1 → 0.1.2

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: 4f5ed9889a1aa6f7fbb3aeaa6d41342a595aa3ca603f654ada833973e656985c
4
- data.tar.gz: e019eeda236b1260b0b68d1fb375ce82b91a58be48ffec5f040d4b62b268d119
3
+ metadata.gz: 9aee5ffece065c56362b59a6f7514bbe4e6177d0ea2d782ec1e3bb485cd05a53
4
+ data.tar.gz: 709e1c51409f66f3134049dbf555db7c6c94bab0f1a66a629d3174051c186f0b
5
5
  SHA512:
6
- metadata.gz: '0231899eca54164befbd7d6bd7d151058e57f388ff45e4b126827c45e6b7bdca6ca103a4f3c63ce95d3bf2452b2c09319d8bbb731b9356639e5a783212377e12'
7
- data.tar.gz: 424848bdee2d1658227871c4cbf72aa6d6ddcd30b759108a8f3d9a75c9b80447d23300ff7153091940b566ef8e4896cd38f07272e9fdf0b640433b22806d7cbf
6
+ metadata.gz: b16e36125a01866c74585b92580a6391c86f264f44a8cfbd308da0edf48f9d9855f2e464b494c6286eed47a67f7f3655c405b5bd728304e5c6653948e5f03dfb
7
+ data.tar.gz: d6463f08ecefb4ddfcb629e58cf6e385df9f090eefdf3a9bf1928327bb3bbaf67de8a90650e41e93a98fcaa483e0a75f05938d4f593f1a855e36123c58044ff2
data/README.md CHANGED
@@ -22,6 +22,123 @@ Or install it yourself as:
22
22
  $ gem install y-rb_actioncable
23
23
  ```
24
24
 
25
+ ## Usage
26
+
27
+ `yrb-actioncable` provides a module that can be used to extend the capabilities
28
+ of a regular channel with a real-time sync mechanism. `yrb-actioncable`
29
+ implements the same protocol as
30
+ [`y-websocket`](https://github.com/yjs/y-websocket/blob/master/bin/utils.js).
31
+
32
+ It will make sure that a newly connected client will be provided with the
33
+ current state and also syncs changes from the client to the server.
34
+
35
+ ```mermaid
36
+ sequenceDiagram
37
+ Client->>Server: Subscribe to channel
38
+ Server->>Client: Successfully subscribed to channel
39
+
40
+ Server->>Client: Send server-side document state-vector
41
+ Client->>Server: Respond with update based on server-side state-vector
42
+
43
+ Client->>Server: Send client-side document state-vector
44
+ Server->>Client: Respond with update based on client-side state-vector
45
+
46
+ Client->>Server: Send incremental updates from client
47
+ Server->>Client: Send incremental updates from server (broadcast)
48
+ ```
49
+
50
+ In order to use the above described protocol, someone can simply include the
51
+ `Sync` module. There are three methods we need to use:
52
+
53
+ 1. Initiate the connection with initial sync steps: `initiate`
54
+ 1. Integrate any incoming changes: `integrate`
55
+ 1. Broadcast incoming updates to all clients: `sync_to`
56
+
57
+ ```ruby
58
+ # app/channels/sync_channel.rb
59
+ class SyncChannel < ApplicationCable::Channel
60
+ include Y::Actioncable::Sync
61
+
62
+ def subscribed
63
+ stream_from("document-1", coder: ActiveSupport::JSON) do |message|
64
+ # integrate updates in the y-rb document
65
+ integrate(message)
66
+ end
67
+
68
+ # negotiate initial state with client
69
+ initiate
70
+ end
71
+
72
+ def receive(message)
73
+ # broadcast update to all connected clients on all servers
74
+ sync_to("document-1", message)
75
+ end
76
+ end
77
+ ```
78
+
79
+ **⚠️ Attention:** This integration and API eventually change before the final
80
+ release. The goal for `yrb-actioncable` is simplicity and ease-of-use, and the
81
+ current implementation requires at least some knowledge about internals.
82
+
83
+ ### Persistence
84
+
85
+ We can also implement a persistence mechanism with `yrb-actioncable` via hooks.
86
+ This is a quite common use case, and therefore it is relatively simple to add.
87
+
88
+ #### Load document
89
+
90
+ In order to instantiate the document with some state, `yrb-actioncable` expects
91
+ the `load` method to be called with a block that returns a full state update for
92
+ the document. Internally it just calls `Y::Doc#sync(update)`.
93
+
94
+ ```ruby
95
+ class SyncChannel < ApplicationCable::Channel
96
+ include Y::Actioncable::Sync
97
+
98
+ def initialize(connection, identifier, params = nil)
99
+ super
100
+ load { |id| load_doc(id) }
101
+ end
102
+ end
103
+ ```
104
+
105
+ #### Persist document
106
+
107
+ It is usually desirable to persist updates as soon as they arrive. The method
108
+ `persist` expects a block that can process a document full state update for the
109
+ given ID.
110
+
111
+ ```ruby
112
+
113
+ class SyncChannel < ApplicationCable::Channel
114
+ include Y::Actioncable::Sync
115
+
116
+ def subscribed
117
+ stream_for(session, coder: ActiveSupport::JSON) do |_message|
118
+ persist { |id, update| save_doc(id, update) }
119
+ end
120
+ end
121
+ end
122
+ ```
123
+
124
+ #### Example implementation for `load_doc` and `save_doc`
125
+
126
+ We eventually provide storage providers that are easy to use, e.g.
127
+ [`yrb-redis`](https://github.com/y-crdt/yrb-redis), but you can also implement
128
+ your own _store_ methods.
129
+
130
+ ```ruby
131
+ def load_doc(id)
132
+ data = REDIS.get(id)
133
+ data = data.unpack("C*") unless data.nil?
134
+ data
135
+ end
136
+
137
+ def save_doc(id, update)
138
+ REDIS.set(id, update.pack("C*"))
139
+ end
140
+ ```
141
+
25
142
  ## License
26
143
 
27
144
  The gem is available as *open source* under the terms of the
@@ -2,12 +2,40 @@
2
2
 
3
3
  module Y
4
4
  module Actioncable
5
- module Sync # rubocop:disable Metrics/ModuleLength
5
+ # A Sync module for Rails ActionCable channels.
6
+ #
7
+ # This module contains a set of utility methods that allows a relatively
8
+ # convenient implementation of a real-time sync channel. The module
9
+ # implements the synchronization steps described in
10
+ # [`y-protocols/sync`](https://github.com/yjs/y-protocols/blob/master/sync.js).
11
+ #
12
+ # @example Create a SyncChannel including this module
13
+ # class SyncChannel
14
+ # def subscribed
15
+ # # initiate sync & subscribe to updates, with optional persistence mechanism
16
+ # sync_for(session) { |id, update| save_doc(id, update) }
17
+ # end
18
+ #
19
+ # def receive(message)
20
+ # # broadcast update to all connected clients on all servers
21
+ # sync_to(session, message)
22
+ # end
23
+ # end
24
+ module Sync
6
25
  extend ActiveSupport::Concern
7
26
 
27
+ CHANNEL_PREFIX = "sync"
28
+ FIELD_ORIGIN = "origin"
29
+ FIELD_UPDATE = "update"
8
30
  MESSAGE_SYNC = 0
9
31
  MESSAGE_AWARENESS = 1
10
- private_constant :MESSAGE_SYNC, :MESSAGE_AWARENESS
32
+ private_constant(
33
+ :CHANNEL_PREFIX,
34
+ :FIELD_ORIGIN,
35
+ :FIELD_UPDATE,
36
+ :MESSAGE_SYNC,
37
+ :MESSAGE_AWARENESS
38
+ )
11
39
 
12
40
  # Initiate synchronization. Encodes the current state_vector and transmits
13
41
  # to the connecting client.
@@ -25,15 +53,14 @@ module Y
25
53
  # This methods should be passed as a block to stream subscription, and not
26
54
  # be put into a generic #receive method.
27
55
  #
28
- # @param [Y::Doc] doc
29
56
  # @param [Hash] message The encoded message must include a field named
30
57
  # exactly like the field argument. The field value must be a Base64
31
58
  # binary.
32
59
  # @param [String] field The field that the encoded update should be
33
60
  # extracted from.
34
- def integrate(message, field: "update")
35
- origin = message["origin"]
36
- update = Y::Lib0::Decoding.decode_base64_to_uint8_array(message["update"])
61
+ def integrate(message, field: FIELD_UPDATE) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
62
+ origin = message[FIELD_ORIGIN]
63
+ update = Y::Lib0::Decoding.decode_base64_to_uint8_array(message[field])
37
64
 
38
65
  encoder = Y::Lib0::Encoding.create_encoder
39
66
  decoder = Y::Lib0::Decoding.create_decoder(update)
@@ -54,6 +81,8 @@ module Y
54
81
  end
55
82
  when MESSAGE_AWARENESS
56
83
  # TODO: implement awareness https://github.com/yjs/y-websocket/blob/master/bin/utils.js#L179-L181
84
+ else
85
+ raise "unexpected message_type=`#{message_type}`"
57
86
  end
58
87
 
59
88
  # do not transmit message back to current connection if the connection
@@ -61,8 +90,80 @@ module Y
61
90
  transmit(message) if origin != connection.connection_identifier
62
91
  end
63
92
 
64
- def sync_to(to, message, field: "update")
65
- update = message["update"]
93
+ # Sync for given model. This is a utility method that simplifies the setup
94
+ # of a sync channel.
95
+ #
96
+ # @param [Object] model
97
+ #
98
+ # for block { |id, update| … }
99
+ # @yield [id, update] Optional block that allows to persist the document
100
+ #
101
+ # @yieldparam [String] id The document ID
102
+ # @yieldparam [Array<Integer>] update The full document state as binary
103
+ # encoded update
104
+ def sync_for(model, &block)
105
+ stream_for(model, coder: ActiveSupport::JSON) do |message|
106
+ # integrate updates in the y-rb document
107
+ integrate(message)
108
+
109
+ # persist document
110
+ persist(&block) if block
111
+ end
112
+
113
+ # negotiate initial state with client
114
+ initiate
115
+ end
116
+
117
+ # Sync for given stream. This is a utility method that simplifies the
118
+ # setup of a sync channel.
119
+ #
120
+ # @param [String] broadcasting
121
+ #
122
+ # for block { |id, update| … }
123
+ # @yield [id, update] Optional block that allows to persist the document
124
+ #
125
+ # @yieldparam [String] id The document ID
126
+ # @yieldparam [Array<Integer>] update The full document state as binary
127
+ # encoded update
128
+ def sync_from(broadcasting, &block)
129
+ stream_from(broadcasting, coder: ActiveSupport::JSON) do |message|
130
+ # integrate updates in the y-rb document
131
+ integrate(message)
132
+
133
+ # persist document
134
+ persist(&block) if block
135
+ end
136
+
137
+ # negotiate initial state with client
138
+ initiate
139
+ end
140
+
141
+ # Synchronize update with all other connected clients (and server
142
+ # processes).
143
+ #
144
+ # @param [String] broadcasting
145
+ # @param [Hash] message
146
+ # @param [optional, String] field
147
+ def sync(broadcasting, message, field: FIELD_UPDATE)
148
+ update = message[field]
149
+
150
+ # we broadcast to all connected clients, but provide the
151
+ # connection_identifier as origin so that the [#integrate] method is
152
+ # able to filter sending back the update to its origin.
153
+ self.class.broadcast(
154
+ broadcasting,
155
+ { update: update, origin: connection.connection_identifier }
156
+ )
157
+ end
158
+
159
+ # Synchronize update with all other connected clients (and server
160
+ # processes).
161
+ #
162
+ # @param [Object] to
163
+ # @param [Hash] message
164
+ # @param [optional, String] field
165
+ def sync_to(to, message, field: FIELD_UPDATE)
166
+ update = message[field]
66
167
 
67
168
  # we broadcast to all connected clients, but provide the
68
169
  # connection_identifier as origin so that the [#integrate] method is
@@ -101,19 +202,58 @@ module Y
101
202
  "#{k.to_s.parameterize}-#{v.to_s.parameterize}"
102
203
  end
103
204
 
104
- "sync:#{params_part.join(":")}"
205
+ "#{CHANNEL_PREFIX}:#{params_part.join(":")}"
105
206
  end
106
207
  end
107
208
 
209
+ # Load the current state of a document from an external source and returns
210
+ # a reference to the document.
211
+ #
212
+ # for block { |id| … }
213
+ # @yield [id] Read document from e.g. an external store
214
+ #
215
+ # @yieldparam [String] id The document ID
216
+ # @yieldreturn [Array<Integer>] The binary encoded state of the document
217
+ # @return [Y::Doc] A reference to the loaded document
108
218
  def load(&block)
109
- full_diff = yield(canonical_channel_key)
219
+ full_diff = nil
220
+ full_diff = yield(canonical_channel_key) if block
110
221
  doc.sync(full_diff) unless full_diff.nil?
222
+ doc
111
223
  end
112
224
 
225
+ # Persist the current document state to an external store.
226
+ #
227
+ # for block { |id, update| … }
228
+ # @yield [id, update] Store document state to e.g. an external store
229
+ #
230
+ # @yieldparam [String] id The document ID
231
+ # @yieldparam [Array<Integer>] update The full document state as binary
232
+ # encoded state
113
233
  def persist(&block)
114
- yield(canonical_channel_key, doc.diff)
234
+ yield(canonical_channel_key, doc.diff) if block
115
235
  end
116
236
 
237
+ # Creates the document once.
238
+ #
239
+ # This method can be overriden in case the document should be initialized
240
+ # with any state other than an empty one. In conjunction with
241
+ # {Y::Actioncable::Sync#load load}, this allows to provide a document to
242
+ # clients that is restored from a persistent store like Redis or also an
243
+ # ActiveRecord model.
244
+ #
245
+ # @example Initialize a {Y::Doc} from state stored in Redis
246
+ # def doc
247
+ # @doc ||= load { |id| load_doc(id) }
248
+ # end
249
+ #
250
+ # def load_doc(id)
251
+ # data = REDIS.get(id)
252
+ # data = data.unpack("C*") unless data.nil?
253
+ # data
254
+ # end
255
+ #
256
+ # @return [Y::Doc] The initialized document
117
257
  def doc
118
258
  @doc ||= Y::Doc.new
119
259
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Y
4
4
  module Actioncable
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.2"
6
6
  end
7
7
  end
@@ -41,14 +41,10 @@ module Y
41
41
  while decoder.pos < size
42
42
  r = decoder.arr[decoder.pos]
43
43
  decoder.pos += 1
44
- num = num + (r & Binary::BITS7) * mult
44
+ num += ((r & Binary::BITS7) * mult)
45
45
  mult *= 128 # next iteration, shift 7 "more" to the left
46
- if r < Binary::BIT8
47
- return num
48
- end
49
- if num > Integer::MAX
50
- raise "integer out of range"
51
- end
46
+ return num if r < Binary::BIT8
47
+ raise "integer out of range" if num > Integer::MAX
52
48
  end
53
49
  raise "unexpected end of array"
54
50
  end
@@ -24,7 +24,7 @@ module Y
24
24
  size += encoder.bufs[i].size
25
25
  i += 1
26
26
  end
27
- return size
27
+ size
28
28
  end
29
29
 
30
30
  def self.to_uint8_array(encoder) # rubocop:disable Metrics/MethodLength
@@ -3,9 +3,9 @@
3
3
  module Y
4
4
  module Lib0
5
5
  module Integer
6
- N_BYTES = [42].pack('i').size
6
+ N_BYTES = [42].pack("i").size
7
7
  N_BITS = N_BYTES * 16
8
- MAX = 2 ** (N_BITS - 2) - 1
8
+ MAX = (2**(N_BITS - 2)) - 1
9
9
  MIN = -MAX - 1
10
10
  end
11
11
  end
data/lib/y/lib0/sync.rb CHANGED
@@ -7,7 +7,7 @@ module Y
7
7
  write_sync_step2(encoder, doc, Decoding.read_var_uint8_array(decoder))
8
8
  end
9
9
 
10
- def self.read_sync_step2(decoder, doc, transaction_origin)
10
+ def self.read_sync_step2(decoder, doc, _transaction_origin)
11
11
  update = Decoding.read_var_uint8_array(decoder)
12
12
  doc.sync(update)
13
13
  end
@@ -18,7 +18,7 @@ module Y
18
18
  # Create a new TypedArray from a buffer and offset. The projected
19
19
  # @overload initialize(buffer, offset, size)
20
20
  def initialize(*args) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
21
- if args.size.zero?
21
+ if args.empty?
22
22
  super()
23
23
  elsif args.size == 1 && args.first.is_a?(Numeric)
24
24
  super(args.first, 0)
@@ -27,7 +27,7 @@ module Y
27
27
  elsif args.size == 1 && args.first.is_a?(Enumerable)
28
28
  super(args.first.to_a)
29
29
  elsif args.size == 2 && args.first.is_a?(Enumerable) && args.last.is_a?(Numeric)
30
- super(args.first.to_a[(args.last)..-1])
30
+ super(args.first.to_a[(args.last)..])
31
31
  elsif args.size == 3 && args.first.is_a?(Enumerable) && args[1].is_a?(Numeric) && args.last.is_a?(Numeric)
32
32
  super(args.first.to_a[args[1], args.last])
33
33
  else
data/lib/y/sync.rb CHANGED
@@ -31,7 +31,7 @@ module Y
31
31
 
32
32
  # @param [Y::Lib0::Decoding::Decoder] decoder
33
33
  # @param [Y::Doc] doc
34
- # @param [Object] transaction_origin
34
+ # @param [Object, nil] transaction_origin
35
35
  # TODO: y-rb sync does not support transaction origins
36
36
  def self.read_sync_step2(decoder, doc, _transaction_origin)
37
37
  update = Y::Lib0::Decoding.read_var_uint8_array(decoder)
@@ -47,17 +47,17 @@ module Y
47
47
 
48
48
  # @param [Y::Lib0::Decoding::Decoder] decoder
49
49
  # @param [Y::Doc] doc
50
- # @param [Object] transaction_origin
51
- def self.read_update(decoder, doc, _transaction_origin)
52
- read_sync_step2(decoder, doc, _transaction_origin)
50
+ # @param [Object, nil] transaction_origin
51
+ def self.read_update(decoder, doc, transaction_origin)
52
+ read_sync_step2(decoder, doc, transaction_origin)
53
53
  end
54
54
 
55
55
  # @param [Y::Lib0::Decoding::Decoder] decoder
56
56
  # @param [Y::Lib0::Encoding::Encoder] encoder
57
57
  # @param [Y::Doc] doc
58
- # @param [Object] transaction_origin
58
+ # @param [Object, nil] transaction_origin
59
59
  # TODO: y-rb sync does not support transaction origins
60
- def self.read_sync_message(decoder, encoder, doc, transaction_origin)
60
+ def self.read_sync_message(decoder, encoder, doc, transaction_origin) # rubocop:disable Metrics/MethodLength
61
61
  message_type = Y::Lib0::Decoding.read_var_uint(decoder)
62
62
 
63
63
  case message_type
File without changes
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: y-rb_actioncable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hannes Moser
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-01-23 00:00:00.000000000 Z
11
+ date: 2023-01-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -25,20 +25,20 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: 7.0.4
27
27
  - !ruby/object:Gem::Dependency
28
- name: rspec-rails
28
+ name: y-rb
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :development
33
+ version: 0.4.3
34
+ type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '0'
41
- description: Reliable message transmission between one or more Y.js clients.
40
+ version: 0.4.3
41
+ description: An ActionCable companion for Y.js clients.
42
42
  email:
43
43
  - box@hannesmoser.at
44
44
  executables: []
@@ -53,6 +53,7 @@ files:
53
53
  - app/jobs/yrb/actioncable/application_job.rb
54
54
  - app/models/yrb/actioncable/application_record.rb
55
55
  - lib/tasks/yrb/actioncable_tasks.rake
56
+ - lib/y-rb_actioncable.rb
56
57
  - lib/y/actioncable.rb
57
58
  - lib/y/actioncable/config.rb
58
59
  - lib/y/actioncable/config/abstract_builder.rb
@@ -71,7 +72,6 @@ files:
71
72
  - lib/y/lib0/sync.rb
72
73
  - lib/y/lib0/typed_array.rb
73
74
  - lib/y/sync.rb
74
- - lib/yrb-actioncable.rb
75
75
  homepage: https://github.com/y-crdt/yrb-actioncable
76
76
  licenses:
77
77
  - MIT
@@ -96,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
96
96
  - !ruby/object:Gem::Version
97
97
  version: '0'
98
98
  requirements: []
99
- rubygems_version: 3.4.1
99
+ rubygems_version: 3.4.4
100
100
  signing_key:
101
101
  specification_version: 4
102
102
  summary: An ActionCable companion for Y.js clients.