y-rb_actioncable 0.1.1 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f5ed9889a1aa6f7fbb3aeaa6d41342a595aa3ca603f654ada833973e656985c
4
- data.tar.gz: e019eeda236b1260b0b68d1fb375ce82b91a58be48ffec5f040d4b62b268d119
3
+ metadata.gz: 2c1bd91dd64aefce65bac62b72f51bf0441e222fb5c865a45a3257addbc5f4dd
4
+ data.tar.gz: 48074bedce935ac2adabf6d4f34942bb1c2ba1f08e1d278c3004b508f1b0eedf
5
5
  SHA512:
6
- metadata.gz: '0231899eca54164befbd7d6bd7d151058e57f388ff45e4b126827c45e6b7bdca6ca103a4f3c63ce95d3bf2452b2c09319d8bbb731b9356639e5a783212377e12'
7
- data.tar.gz: 424848bdee2d1658227871c4cbf72aa6d6ddcd30b759108a8f3d9a75c9b80447d23300ff7153091940b566ef8e4896cd38f07272e9fdf0b640433b22806d7cbf
6
+ metadata.gz: 7ecaf95ff7925f08a035e54f41290fb939f6ae40666af50d736771df103aa14b988b84cd08bbfbfa006320eac72d63f573d3afa1f172f51abcbf28c6b2a06a6c
7
+ data.tar.gz: 3a734d0f445edfc492901efe1219e72d39b9321e5e00fbe8f56a248882ff6822ab276d294dd219570d51dde8a14ee3f4baf6cb401db7adcd1790eaa4764ca0e6
data/README.md CHANGED
@@ -22,6 +22,118 @@ 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
+ # initiate sync & subscribe to updates, with optional persistence mechanism
64
+ sync_from("document-1")
65
+ end
66
+
67
+ def receive(message)
68
+ # broadcast update to all connected clients on all servers
69
+ sync_to("document-1", message)
70
+ end
71
+ end
72
+ ```
73
+
74
+ **⚠️ Attention:** This integration and API eventually change before the final
75
+ release. The goal for `yrb-actioncable` is simplicity and ease-of-use, and the
76
+ current implementation requires at least some knowledge about internals.
77
+
78
+ ### Persistence
79
+
80
+ We can also implement a persistence mechanism with `yrb-actioncable` via hooks.
81
+ This is a quite common use case, and therefore it is relatively simple to add.
82
+
83
+ #### Load document
84
+
85
+ In order to instantiate the document with some state, `yrb-actioncable` expects
86
+ the `load` method to be called with a block that returns a full state update for
87
+ the document. Internally it just calls `Y::Doc#sync(update)`.
88
+
89
+ ```ruby
90
+ class SyncChannel < ApplicationCable::Channel
91
+ include Y::Actioncable::Sync
92
+
93
+ def initialize(connection, identifier, params = nil)
94
+ super
95
+ load { |id| load_doc(id) }
96
+ end
97
+ end
98
+ ```
99
+
100
+ #### Persist document
101
+
102
+ It is usually desirable to persist updates as soon as they arrive. The method
103
+ `persist` expects a block that can process a document full state update for the
104
+ given ID.
105
+
106
+ ```ruby
107
+
108
+ class SyncChannel < ApplicationCable::Channel
109
+ include Y::Actioncable::Sync
110
+
111
+ def subscribed
112
+ stream_for(session, coder: ActiveSupport::JSON) do |_message|
113
+ persist { |id, update| save_doc(id, update) }
114
+ end
115
+ end
116
+ end
117
+ ```
118
+
119
+ #### Example implementation for `load_doc` and `save_doc`
120
+
121
+ We eventually provide storage providers that are easy to use, e.g.
122
+ [`yrb-redis`](https://github.com/y-crdt/yrb-redis), but you can also implement
123
+ your own _store_ methods.
124
+
125
+ ```ruby
126
+ def load_doc(id)
127
+ data = REDIS.get(id)
128
+ data = data.unpack("C*") unless data.nil?
129
+ data
130
+ end
131
+
132
+ def save_doc(id, update)
133
+ REDIS.set(id, update.pack("C*"))
134
+ end
135
+ ```
136
+
25
137
  ## License
26
138
 
27
139
  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
@@ -96,27 +197,73 @@ module Y
96
197
  # "issue_channel:id:1"
97
198
  def canonical_channel_key
98
199
  @canonical_channel_key ||= begin
99
- pairs = JSON.parse!(identifier)
100
- params_part = pairs.map do |k, v|
200
+ params_part = channel_identifier.map do |k, v|
101
201
  "#{k.to_s.parameterize}-#{v.to_s.parameterize}"
102
202
  end
103
203
 
104
- "sync:#{params_part.join(":")}"
204
+ "#{CHANNEL_PREFIX}:#{params_part.join(":")}"
105
205
  end
106
206
  end
107
207
 
208
+ # Load the current state of a document from an external source and returns
209
+ # a reference to the document.
210
+ #
211
+ # for block { |id| … }
212
+ # @yield [id] Read document from e.g. an external store
213
+ #
214
+ # @yieldparam [String] id The document ID
215
+ # @yieldreturn [Array<Integer>] The binary encoded state of the document
216
+ # @return [Y::Doc] A reference to the loaded document
108
217
  def load(&block)
109
- full_diff = yield(canonical_channel_key)
218
+ full_diff = nil
219
+ full_diff = yield(canonical_channel_key) if block
110
220
  doc.sync(full_diff) unless full_diff.nil?
221
+ doc
111
222
  end
112
223
 
224
+ # Persist the current document state to an external store.
225
+ #
226
+ # for block { |id, update| … }
227
+ # @yield [id, update] Store document state to e.g. an external store
228
+ #
229
+ # @yieldparam [String] id The document ID
230
+ # @yieldparam [Array<Integer>] update The full document state as binary
231
+ # encoded state
113
232
  def persist(&block)
114
- yield(canonical_channel_key, doc.diff)
233
+ yield(canonical_channel_key, doc.diff) if block
115
234
  end
116
235
 
236
+ # Creates the document once.
237
+ #
238
+ # This method can be overriden in case the document should be initialized
239
+ # with any state other than an empty one. In conjunction with
240
+ # {Y::Actioncable::Sync#load load}, this allows to provide a document to
241
+ # clients that is restored from a persistent store like Redis or also an
242
+ # ActiveRecord model.
243
+ #
244
+ # @example Initialize a {Y::Doc} from state stored in Redis
245
+ # def doc
246
+ # @doc ||= load { |id| load_doc(id) }
247
+ # end
248
+ #
249
+ # def load_doc(id)
250
+ # data = REDIS.get(id)
251
+ # data = data.unpack("C*") unless data.nil?
252
+ # data
253
+ # end
254
+ #
255
+ # @return [Y::Doc] The initialized document
117
256
  def doc
118
257
  @doc ||= Y::Doc.new
119
258
  end
259
+
260
+ private
261
+
262
+ def channel_identifier
263
+ return ["test", identifier] if Rails.env.test?
264
+
265
+ JSON.parse(identifier)
266
+ end
120
267
  end
121
268
  end
122
269
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Y
4
4
  module Actioncable
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.3"
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.3
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-31 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.5
100
100
  signing_key:
101
101
  specification_version: 4
102
102
  summary: An ActionCable companion for Y.js clients.