y-rb_actioncable 0.1.1 → 0.1.3

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: 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.