y-rb_actioncable 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4f5ed9889a1aa6f7fbb3aeaa6d41342a595aa3ca603f654ada833973e656985c
4
+ data.tar.gz: e019eeda236b1260b0b68d1fb375ce82b91a58be48ffec5f040d4b62b268d119
5
+ SHA512:
6
+ metadata.gz: '0231899eca54164befbd7d6bd7d151058e57f388ff45e4b126827c45e6b7bdca6ca103a4f3c63ce95d3bf2452b2c09319d8bbb731b9356639e5a783212377e12'
7
+ data.tar.gz: 424848bdee2d1658227871c4cbf72aa6d6ddcd30b759108a8f3d9a75c9b80447d23300ff7153091940b566ef8e4896cd38f07272e9fdf0b640433b22806d7cbf
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # yrb-actioncable
2
+
3
+ > An ActionCable companion for Y.js clients
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "y-rb_actioncable"
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```bash
22
+ $ gem install y-rb_actioncable
23
+ ```
24
+
25
+ ## License
26
+
27
+ The gem is available as *open source* under the terms of the
28
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
6
+
7
+ load "rails/tasks/engine.rake"
8
+ load "rails/tasks/statistics.rake"
9
+
10
+ require "bundler/gem_tasks"
11
+
12
+ begin
13
+ require "rspec/core"
14
+ require "rspec/core/rake_task"
15
+
16
+ desc "Run all specs in spec directory (excluding plugin specs)"
17
+ RSpec::Core::RakeTask.new(spec: "app:db:test:prepare")
18
+
19
+ task test: :spec
20
+ task default: %i[test]
21
+ rescue LoadError
22
+ # Ok
23
+ end
24
+
25
+ begin
26
+ require "rubocop/rake_task"
27
+
28
+ RuboCop::RakeTask.new
29
+ rescue LoadError
30
+ # Ok
31
+ end
32
+
33
+ begin
34
+ require "yard"
35
+
36
+ YARD::Rake::YardocTask.new
37
+
38
+ task docs: :environment do
39
+ `yard server --reload`
40
+ end
41
+ rescue LoadError
42
+ # Ok
43
+ end
44
+
45
+ namespace :app do
46
+ task template: "app:app:template"
47
+ end
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/yrb/actioncable .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ module ApplicationHelper
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ class ApplicationJob < ActiveJob::Base
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ # desc "Explaining what the task does"
3
+ # task :yrb_actioncable do
4
+ # # Task goes here
5
+ # end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ class Config
6
+ # Abstract base class for y-rb_actioncable and it's extensions
7
+ # configuration builder. Instantiates and validates gem configuration.
8
+ class AbstractBuilder
9
+ attr_reader :config
10
+
11
+ # @param [Class] config class
12
+ #
13
+ def initialize(config = Config.new, &block)
14
+ @config = config
15
+ instance_eval(&block)
16
+ end
17
+
18
+ # Builds and validates configuration.
19
+ #
20
+ # @return [Y::Actioncable::Config] config instance
21
+ #
22
+ def build
23
+ @config.validate! if @config.respond_to?(:validate!)
24
+ @config
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ class Config
6
+ # Configuration option DSL
7
+ module Option
8
+ # Defines configuration option
9
+ #
10
+ # When you call option, it defines two methods. One method will take place
11
+ # in the +Config+ class and the other method will take place in the
12
+ # +Builder+ class.
13
+ #
14
+ # The +name+ parameter will set both builder method and config attribute.
15
+ # If the +:as+ option is defined, the builder method will be the specified
16
+ # option while the config attribute will be the +name+ parameter.
17
+ #
18
+ # If you want to introduce another level of config DSL you can
19
+ # define +builder_class+ parameter.
20
+ # Builder should take a block as the initializer parameter and respond to function +build+
21
+ # that returns the value of the config attribute.
22
+ #
23
+ # ==== Options
24
+ #
25
+ # * [:+as+] Set the builder method that goes inside +configure+ block
26
+ # * [+:default+] The default value in case no option was set
27
+ # * [+:builder_class+] Configuration option builder class
28
+ #
29
+ # ==== Examples
30
+ #
31
+ # option :name
32
+ # option :name, as: :set_name
33
+ # option :name, default: 'My Name'
34
+ # option :scopes builder_class: ScopesBuilder
35
+ #
36
+ def option(name, options = {}) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
37
+ attribute = options[:as] || name
38
+ attribute_builder = options[:builder_class]
39
+
40
+ builder_class.instance_eval do
41
+ if method_defined?(name)
42
+ Kernel.warn "[ISORUN] Option #{name} already defined and will be overridden"
43
+ remove_method name
44
+ end
45
+
46
+ define_method name do |*args, &block| # rubocop:disable Metrics/MethodLength
47
+ if (deprecation_opts = options[:deprecated])
48
+ warning = "[ISORUN] #{name} has been deprecated and will soon be removed"
49
+ warning = "#{warning}\n#{deprecation_opts.fetch(:message)}" if deprecation_opts.is_a?(Hash)
50
+
51
+ Kernel.warn(warning)
52
+ end
53
+
54
+ value = if attribute_builder
55
+ attribute_builder.new(&block).build
56
+ else
57
+ block || args.first
58
+ end
59
+
60
+ @config.instance_variable_set(:"@#{attribute}", value)
61
+ end
62
+ end
63
+
64
+ define_method attribute do |*_args|
65
+ if instance_variable_defined?(:"@#{attribute}")
66
+ instance_variable_get(:"@#{attribute}")
67
+ else
68
+ options[:default]
69
+ end
70
+ end
71
+
72
+ public attribute
73
+ end
74
+
75
+ def self.extended(base)
76
+ return if base.respond_to?(:builder_class)
77
+
78
+ raise Y::Actioncable::MissingConfigurationBuilderClass,
79
+ "Define `self.builder_class` method for #{base} that returns " \
80
+ "your custom Builder class to use options DSL!"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ class Config
6
+ # Configuration validator
7
+ module Validations
8
+ # Validates configuration options to be set properly.
9
+ def validate!; end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "y/actioncable/config/abstract_builder"
4
+ require "y/actioncable/config/option"
5
+ require "y/actioncable/config/validations"
6
+
7
+ # inspired by https://github.com/doorkeeper-gem/doorkeeper/blob/main/lib/doorkeeper/config.rb
8
+ module Y
9
+ module Actioncable
10
+ class MissingConfiguration < StandardError
11
+ def initialize
12
+ super("Configuration for y-rb_actioncable is missing. " \
13
+ "Do you have an initializer?")
14
+ end
15
+ end
16
+
17
+ class MissingConfigurationBuilderClass < StandardError; end
18
+
19
+ class << self
20
+ def configure(&block)
21
+ @config = Config::Builder.new(&block).build
22
+ end
23
+
24
+ def configuration
25
+ @config || (raise MissingConfiguration)
26
+ end
27
+
28
+ alias config configuration
29
+ end
30
+
31
+ class Config
32
+ class Builder < AbstractBuilder
33
+ end
34
+
35
+ # Replace with `default: Builder` when we drop support of Rails < 5.2
36
+ mattr_reader(:builder_class) { Builder }
37
+
38
+ extend Option
39
+ include Validations
40
+
41
+ option :redis, default: lambda {
42
+ raise "A Redis client must be configured at initialization time"
43
+ }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Y::Actioncable
7
+
8
+ config.generators do |g|
9
+ g.test_framework :rspec
10
+ g.assets false
11
+ g.helper false
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ module Reliable # rubocop:disable Metrics/ModuleLength
6
+ extend ActiveSupport::Concern
7
+
8
+ KEY_PREFIX = "reliable_stream"
9
+ STREAM_INACTIVE_TIMEOUT = 1.hour
10
+ USER_INACTIVE_TIMEOUT = 15.minutes
11
+ LAST_ID_FIELD = "last_id"
12
+ CLOCK_FIELD = "clock"
13
+
14
+ private_constant(
15
+ :KEY_PREFIX,
16
+ :STREAM_INACTIVE_TIMEOUT,
17
+ :USER_INACTIVE_TIMEOUT,
18
+ :LAST_ID_FIELD
19
+ )
20
+
21
+ included do
22
+ unless method_defined? :current_user
23
+ raise "`current_user` is not defined. A ReliableChannel requires " \
24
+ "current_user to be an instance of a `User` model."
25
+ end
26
+ end
27
+
28
+ class_methods do
29
+ attr_reader :registered_reliable_actions
30
+
31
+ def reliable_broadcast(method) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
32
+ method_name = if method.is_a? Symbol
33
+ method.to_s
34
+ else
35
+ method
36
+ end
37
+ method_sym = method_name.to_sym
38
+
39
+ @registered_reliable_actions ||= Set.new
40
+ @registered_reliable_actions.add(method_name)
41
+
42
+ # broadcast received data to all clients
43
+ define_method method_sym do |data| # rubocop:disable Metrics/MethodLength
44
+ key = stream_key(method_name)
45
+
46
+ # add new entry to stream
47
+ last_id = with_redis do |redis|
48
+ redis.xadd(key, { data: data[:data] })
49
+ end
50
+
51
+ # broadcast new entry to all clients
52
+ ActionCable.server.broadcast(
53
+ key,
54
+ {
55
+ last_id: last_id,
56
+ clock: data[CLOCK_FIELD],
57
+ data: data[:data]
58
+ }
59
+ )
60
+ end
61
+
62
+ # acknowledge last known ID (by current user)
63
+ define_method "ack_#{method_name}".to_sym do |data|
64
+ key = stream_ack_key(method_name)
65
+ with_redis do |redis|
66
+ score = map_entry_id_to_score(data[LAST_ID_FIELD])
67
+ redis.zadd(key, [score, current_user.id], gt: true)
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # we need to override this method to inject our own callback for subscribe
74
+ # without forcing the user to know about calling super() for #subscribe
75
+ def subscribe_to_channel
76
+ run_callbacks :subscribe do
77
+ reliable_subscribed
78
+ end
79
+
80
+ super
81
+ end
82
+
83
+ def unsubscribe_from_channel # :nodoc:
84
+ run_callbacks :unsubscribe do
85
+ reliable_unsubscribed
86
+ end
87
+
88
+ super
89
+ end
90
+
91
+ protected
92
+
93
+ def id
94
+ params[:id]
95
+ end
96
+
97
+ private
98
+
99
+ def reliable_subscribed # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
100
+ # reject subscription if there is no ID present
101
+ if id.blank?
102
+ Rails.logger.error("`id` is not present. The parameter :id must be " \
103
+ "present in order for ReliableChannel to work.")
104
+
105
+ reject_subscription
106
+ end
107
+
108
+ # create a reliable stream for all registered actions
109
+ self.class.registered_reliable_actions.each do |reliable_action|
110
+ key = stream_key(reliable_action)
111
+ stream_from key
112
+ end
113
+
114
+ # create a stream for the current user
115
+ stream_for current_user
116
+
117
+ # initialize per-user and per-stream state
118
+ with_redis do |redis|
119
+ redis.pipelined do |pipeline|
120
+ self.class.registered_reliable_actions
121
+ .map do |reliable_action|
122
+ key = stream_ack_key(reliable_action)
123
+ pipeline.zadd(key, [-1, current_user.id])
124
+ end.flatten
125
+ end
126
+ end
127
+ end
128
+
129
+ def reliable_unsubscribed
130
+ # remove all user state from registered reliable streams
131
+ with_redis do |redis|
132
+ redis.pipelined do |pipeline|
133
+ self.class.registered_reliable_actions.map do |reliable_action|
134
+ key = stream_ack_key(reliable_action)
135
+ pipeline.zrem(key, current_user.id)
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ def user_key
142
+ "user:#{current_user.id}"
143
+ end
144
+
145
+ def stream_key(method)
146
+ "#{KEY_PREFIX}:#{method}:#{id}"
147
+ end
148
+
149
+ def stream_ack_key(method)
150
+ "#{stream_key(method)}:ack"
151
+ end
152
+
153
+ # Trim stream up to the minimum commonly shared entry ID across all
154
+ # registered clients.
155
+ def trim_stream(key, min_id)
156
+ with_redis do |redis|
157
+ redis.xtrim(key, min_id)
158
+ end
159
+ end
160
+
161
+ # Maps a Redis stream entry ID to a value that can be used as a score
162
+ # value in a Redis sorted set. The max score value is 2^53
163
+ # (https://redis.io/commands/zadd/). A Unix epoch represented in ms, and
164
+ # calculated in 2022 is around 2^40. If we pad the counter by a max value
165
+ # of 2^10, we can safely store values up to the year 2248 (2^43).
166
+ #
167
+ # This allows us to store up to a max of 999 concurrent messages for a
168
+ # given ID, within a given channel, within the same millisecond. The
169
+ # method will raise if the counter part of the entry_id exceeds the limit.
170
+ def map_entry_id_to_score(entry_id)
171
+ ts, c = entry_id.split("-")
172
+ if c.to_i > 999
173
+ raise "concurrent message counter exceeds 99 and cannot be " \
174
+ "concat with the timestamp"
175
+ end
176
+ # pad counter, this allows up to 9999 concurrent messages within the
177
+ # same ms
178
+ c.rjust(3, "0")
179
+ "#{ts}#{c}".to_i
180
+ end
181
+
182
+ # Reverse the mapping done in Reliable#map_entry_id_to_score
183
+ def map_score_to_entry_id(score)
184
+ score = score.to_s
185
+ c_padded, ts = score.slice!(-3..), score # rubocop:disable Style/ParallelAssignment
186
+ "#{ts}-#{c_padded.to_i}"
187
+ end
188
+
189
+ # Provide access to a Redis client
190
+ #
191
+ # @return [Redis] The Redis client
192
+ def with_redis(&block)
193
+ raise "no block given" if block.blank?
194
+
195
+ Y::Actioncable.configuration.redis.call(block)
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ module Sync # rubocop:disable Metrics/ModuleLength
6
+ extend ActiveSupport::Concern
7
+
8
+ MESSAGE_SYNC = 0
9
+ MESSAGE_AWARENESS = 1
10
+ private_constant :MESSAGE_SYNC, :MESSAGE_AWARENESS
11
+
12
+ # Initiate synchronization. Encodes the current state_vector and transmits
13
+ # to the connecting client.
14
+ def initiate
15
+ encoder = Y::Lib0::Encoding.create_encoder
16
+ Y::Lib0::Encoding.write_var_uint(encoder, MESSAGE_SYNC)
17
+ Y::Sync.write_sync_step1(encoder, doc)
18
+ update = Y::Lib0::Encoding.to_uint8_array(encoder)
19
+ update = Y::Lib0::Encoding.encode_uint8_array_to_base64(update)
20
+
21
+ transmit({ update: update })
22
+ # TODO: implement awareness https://github.com/yjs/y-websocket/blob/master/bin/utils.js#L278-L284
23
+ end
24
+
25
+ # This methods should be passed as a block to stream subscription, and not
26
+ # be put into a generic #receive method.
27
+ #
28
+ # @param [Y::Doc] doc
29
+ # @param [Hash] message The encoded message must include a field named
30
+ # exactly like the field argument. The field value must be a Base64
31
+ # binary.
32
+ # @param [String] field The field that the encoded update should be
33
+ # extracted from.
34
+ def integrate(message, field: "update")
35
+ origin = message["origin"]
36
+ update = Y::Lib0::Decoding.decode_base64_to_uint8_array(message["update"])
37
+
38
+ encoder = Y::Lib0::Encoding.create_encoder
39
+ decoder = Y::Lib0::Decoding.create_decoder(update)
40
+ message_type = Y::Lib0::Decoding.read_var_uint(decoder)
41
+ case message_type
42
+ when MESSAGE_SYNC
43
+ Y::Lib0::Encoding.write_var_uint(encoder, MESSAGE_SYNC)
44
+ Y::Sync.read_sync_message(decoder, encoder, doc, nil)
45
+
46
+ # If the `encoder` only contains the type of reply message and no
47
+ # message, there is no need to send the message. When `encoder` only
48
+ # contains the type of reply, its length is 1.
49
+ if Y::Lib0::Encoding.length(encoder) > 1
50
+ update = Y::Lib0::Encoding.to_uint8_array(encoder)
51
+ update = Y::Lib0::Encoding.encode_uint8_array_to_base64(update)
52
+
53
+ transmit({ update: update })
54
+ end
55
+ when MESSAGE_AWARENESS
56
+ # TODO: implement awareness https://github.com/yjs/y-websocket/blob/master/bin/utils.js#L179-L181
57
+ end
58
+
59
+ # do not transmit message back to current connection if the connection
60
+ # is the origin of the message
61
+ transmit(message) if origin != connection.connection_identifier
62
+ end
63
+
64
+ def sync_to(to, message, field: "update")
65
+ update = message["update"]
66
+
67
+ # we broadcast to all connected clients, but provide the
68
+ # connection_identifier as origin so that the [#integrate] method is
69
+ # able to filter sending back the update to its origin.
70
+ self.class.broadcast_to(
71
+ to,
72
+ { update: update, origin: connection.connection_identifier }
73
+ )
74
+ end
75
+
76
+ # Produce a canonical key for this channel and its parameters. This allows
77
+ # us to create unique documents for separate use cases. e.g. an Issue is
78
+ # the document scope, but has multiple fields that are synchronized, the
79
+ # title, description, labels, …
80
+ #
81
+ # By default, the key is the same as the channel identifier.
82
+ #
83
+ # @example Create a new IssueChannel that sync updates for issue ID
84
+ # # issue_channel.rb
85
+ # class IssueChannel
86
+ # include Y::Actionable::SyncChannel
87
+ # end
88
+ #
89
+ # # issue_subscription.js
90
+ # const params = { id: 1 }
91
+ # consumer.subscriptions.create(
92
+ # {channel: "IssueChannel", ...params}
93
+ # );
94
+ #
95
+ # # example for a resulting canonical key
96
+ # "issue_channel:id:1"
97
+ def canonical_channel_key
98
+ @canonical_channel_key ||= begin
99
+ pairs = JSON.parse!(identifier)
100
+ params_part = pairs.map do |k, v|
101
+ "#{k.to_s.parameterize}-#{v.to_s.parameterize}"
102
+ end
103
+
104
+ "sync:#{params_part.join(":")}"
105
+ end
106
+ end
107
+
108
+ def load(&block)
109
+ full_diff = yield(canonical_channel_key)
110
+ doc.sync(full_diff) unless full_diff.nil?
111
+ end
112
+
113
+ def persist(&block)
114
+ yield(canonical_channel_key, doc.diff)
115
+ end
116
+
117
+ def doc
118
+ @doc ||= Y::Doc.new
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Actioncable
5
+ VERSION = "0.1.1"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "y/actioncable/config"
4
+ require "y/actioncable/engine"
5
+ require "y/actioncable/reliable"
6
+ require "y/actioncable/sync"
7
+ require "y/actioncable/version"
8
+
9
+ module Y
10
+ module Actioncable
11
+ # Your code goes here...
12
+ end
13
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Lib0
5
+ module Binary
6
+ BIT1 = 1
7
+ BIT2 = 2
8
+ BIT3 = 4
9
+ BIT4 = 8
10
+ BIT5 = 16
11
+ BIT6 = 32
12
+ BIT7 = 64
13
+ BIT8 = 128
14
+ BIT9 = 256
15
+ BIT10 = 512
16
+ BIT11 = 1024
17
+ BIT12 = 2048
18
+ BIT13 = 4096
19
+ BIT14 = 8192
20
+ BIT15 = 16_384
21
+ BIT16 = 32_768
22
+ BIT17 = 65_536
23
+ BIT18 = 1 << 17
24
+ BIT19 = 1 << 18
25
+ BIT20 = 1 << 19
26
+ BIT21 = 1 << 20
27
+ BIT22 = 1 << 21
28
+ BIT23 = 1 << 22
29
+ BIT24 = 1 << 23
30
+ BIT25 = 1 << 24
31
+ BIT26 = 1 << 25
32
+ BIT27 = 1 << 26
33
+ BIT28 = 1 << 27
34
+ BIT29 = 1 << 28
35
+ BIT30 = 1 << 29
36
+ BIT31 = 1 << 30
37
+ BIT32 = 1 << 31
38
+
39
+ BITS0 = 0
40
+ BITS1 = 1
41
+ BITS2 = 3
42
+ BITS3 = 7
43
+ BITS4 = 15
44
+ BITS5 = 31
45
+ BITS6 = 63
46
+ BITS7 = 127
47
+ BITS8 = 255
48
+ BITS9 = 511
49
+ BITS10 = 1023
50
+ BITS11 = 2047
51
+ BITS12 = 4095
52
+ BITS13 = 8191
53
+ BITS14 = 16_383
54
+ BITS15 = 32_767
55
+ BITS16 = 65_535
56
+ BITS17 = BIT18 - 1
57
+ BITS18 = BIT19 - 1
58
+ BITS19 = BIT20 - 1
59
+ BITS20 = BIT21 - 1
60
+ BITS21 = BIT22 - 1
61
+ BITS22 = BIT23 - 1
62
+ BITS23 = BIT24 - 1
63
+ BITS24 = BIT25 - 1
64
+ BITS25 = BIT26 - 1
65
+ BITS26 = BIT27 - 1
66
+ BITS27 = BIT28 - 1
67
+ BITS28 = BIT29 - 1
68
+ BITS29 = BIT30 - 1
69
+ BITS30 = BIT31 - 1
70
+
71
+ BITS31 = 0x7FFFFFFF
72
+
73
+ BITS32 = 0xFFFFFFFF
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Lib0
5
+ module Buffer
6
+ def self.create_uint8_array_view_from_buffer(enumerable, offset, size)
7
+ TypedArray.new(enumerable, offset, size)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Lib0
5
+ module Decoding
6
+ class Decoder
7
+ attr_accessor :arr, :pos
8
+
9
+ def initialize(uint8_array)
10
+ @arr = uint8_array
11
+ @pos = 0
12
+ end
13
+ end
14
+
15
+ def self.create_decoder(uint8_array)
16
+ Decoder.new(uint8_array)
17
+ end
18
+
19
+ def self.has_content(decoder)
20
+ decoder.pos != decoder.arr.size
21
+ end
22
+
23
+ def self.clone(decoder, new_pos = decoder.pos)
24
+ decoder = create_decoder(decoder.arr)
25
+ decoder.pos = new_pos
26
+ decoder
27
+ end
28
+
29
+ def self.read_uint8_array(decoder, size)
30
+ view = Buffer.create_uint8_array_view_from_buffer(decoder.arr, decoder.pos + 0, size)
31
+ end
32
+
33
+ def self.read_var_uint8_array(decoder)
34
+ read_uint8_array(decoder, read_var_uint(decoder))
35
+ end
36
+
37
+ def self.read_var_uint(decoder)
38
+ num = 0
39
+ mult = 1
40
+ size = decoder.arr.size
41
+ while decoder.pos < size
42
+ r = decoder.arr[decoder.pos]
43
+ decoder.pos += 1
44
+ num = num + (r & Binary::BITS7) * mult
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
52
+ end
53
+ raise "unexpected end of array"
54
+ end
55
+
56
+ def self.decode_base64_to_uint8_array(str)
57
+ Base64.strict_decode64(str).unpack("C*")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Lib0
5
+ module Encoding
6
+ class Encoder
7
+ attr_accessor :bufs, :cpos, :cbuf
8
+
9
+ def initialize
10
+ @cpos = 0
11
+ @cbuf = TypedArray.new(100)
12
+ @bufs = []
13
+ end
14
+ end
15
+
16
+ def self.create_encoder
17
+ Encoder.new
18
+ end
19
+
20
+ def self.length(encoder)
21
+ size = encoder.cpos
22
+ i = 0
23
+ while i < encoder.bufs.size
24
+ size += encoder.bufs[i].size
25
+ i += 1
26
+ end
27
+ return size
28
+ end
29
+
30
+ def self.to_uint8_array(encoder) # rubocop:disable Metrics/MethodLength
31
+ typed_arr = TypedArray.new(length(encoder))
32
+ cur_pos = 0
33
+ i = 0
34
+ while i < encoder.bufs.size
35
+ d = encoder.bufs[i]
36
+ typed_arr.replace_with(d, cur_pos)
37
+ cur_pos += d.size
38
+ i += 1
39
+ end
40
+ typed_arr.replace_with(
41
+ Buffer.create_uint8_array_view_from_buffer(
42
+ encoder.cbuf,
43
+ 0,
44
+ encoder.cpos
45
+ ),
46
+ cur_pos
47
+ )
48
+ typed_arr
49
+ end
50
+
51
+ def self.verify_size(encoder, size)
52
+ buffer_size = encoder.cbuf.size
53
+
54
+ return unless buffer_size - encoder.cpos < size
55
+
56
+ encoder.bufs << Buffer.create_uint8_array_view_from_buffer(encoder.cbuf, 0, encoder.cpos)
57
+ encoder.cbuf = TypedArray.new([buffer_size, size].max * 2)
58
+ encoder.cpos = 0
59
+ end
60
+
61
+ def self.write(encoder, num)
62
+ buffer_size = encoder.cbuf.size
63
+ if encoder.cpos == buffer_size
64
+ encoder.bufs << encoder.cbuf
65
+ encoder.cbuf = TypedArray.new(buffer_size * 2)
66
+ encoder.cpos = 0
67
+ end
68
+
69
+ encoder.cbuf[encoder.cpos] = num
70
+ encoder.cpos += 1
71
+ end
72
+
73
+ def self.set(encoder, pos, num) # rubocop:disable Metrics/MethodLength
74
+ buffer = nil
75
+ i = 0
76
+ while i < encoder.bufs.size && buffer.nil?
77
+ b = encoder.bufs[i]
78
+ if pos < b.size
79
+ buffer = b
80
+ else
81
+ pos -= b.size
82
+ end
83
+
84
+ i += 1
85
+ end
86
+
87
+ buffer = encoder.cbuf if buffer.nil?
88
+ buffer[pos] = num
89
+ end
90
+
91
+ def self.write_uint8(encoder, num)
92
+ write(encoder, num)
93
+ end
94
+
95
+ def self.set_uint8(encoder, pos, num)
96
+ set(encoder, pos, num)
97
+ end
98
+
99
+ def self.write_var_uint(encoder, num)
100
+ while num > Binary::BITS7
101
+ write(encoder, Binary::BIT8 | (Binary::BITS7 & num))
102
+ num = (num / 128.0).floor # shift >>> 7
103
+ end
104
+ write(encoder, Binary::BITS7 & num)
105
+ end
106
+
107
+ def self.write_uint8_array(encoder, uint8_array) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
108
+ buffer_size = encoder.cbuf.size
109
+ cpos = encoder.cpos
110
+ left_copy_size = [buffer_size - cpos, uint8_array.size].min
111
+ right_copy_size = uint8_array.size - left_copy_size
112
+ encoder.cbuf.replace_with(uint8_array.slice(0, left_copy_size), cpos)
113
+ encoder.cpos += left_copy_size
114
+
115
+ return unless right_copy_size.positive?
116
+
117
+ # Still something to write, write right half..
118
+ # Append new buffer
119
+ encoder.bufs.push(encoder.cbuf)
120
+ # must have at least size of remaining buffer
121
+ encoder.cbuf = TypedArray.new([buffer_size * 2, right_copy_size].max)
122
+ # copy array
123
+ encoder.cbuf.replace_with(uint8_array[left_copy_size..])
124
+ encoder.cpos = right_copy_size
125
+ end
126
+
127
+ def self.write_var_uint8_array(encoder, uint8_array)
128
+ write_var_uint(encoder, uint8_array.size)
129
+ write_uint8_array(encoder, uint8_array)
130
+ end
131
+
132
+ def self.unsigned_right_shift(value, amount)
133
+ mask = (1 << (32 - amount)) - 1
134
+ (value >> amount) & mask
135
+ end
136
+
137
+ def self.encode_uint8_array_to_base64(arr)
138
+ Base64.strict_encode64(arr.pack("C*"))
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Lib0
5
+ module Integer
6
+ N_BYTES = [42].pack('i').size
7
+ N_BITS = N_BYTES * 16
8
+ MAX = 2 ** (N_BITS - 2) - 1
9
+ MIN = -MAX - 1
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Lib0
5
+ module Sync
6
+ def self.read_sync_step1(decoder, encoder, doc)
7
+ write_sync_step2(encoder, doc, Decoding.read_var_uint8_array(decoder))
8
+ end
9
+
10
+ def self.read_sync_step2(decoder, doc, transaction_origin)
11
+ update = Decoding.read_var_uint8_array(decoder)
12
+ doc.sync(update)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Lib0
5
+ class TypedArray < ::Array
6
+ # @overload initialize()
7
+ # Initialize a TypedArray of size=0
8
+ # @overload initialize(size)
9
+ # Initialize a TypedArray of given size and initialize with 0's
10
+ # @param size [Integer]
11
+ # @overload initialize(typed_array)
12
+ # Create a new TypedArray from an existing
13
+ # @overload initialize(buffer)
14
+ # Create a new TypedArray from a buffer. All elements must be valid
15
+ # integers that fit into a single byte (unsigned int). This is not
16
+ # checked at runtime.
17
+ # @overload initialize(buffer, offset)
18
+ # Create a new TypedArray from a buffer and offset. The projected
19
+ # @overload initialize(buffer, offset, size)
20
+ def initialize(*args) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
21
+ if args.size.zero?
22
+ super()
23
+ elsif args.size == 1 && args.first.is_a?(Numeric)
24
+ super(args.first, 0)
25
+ elsif args.size == 1 && args.first.is_a?(TypedArray)
26
+ super(args.first)
27
+ elsif args.size == 1 && args.first.is_a?(Enumerable)
28
+ super(args.first.to_a)
29
+ elsif args.size == 2 && args.first.is_a?(Enumerable) && args.last.is_a?(Numeric)
30
+ super(args.first.to_a[(args.last)..-1])
31
+ elsif args.size == 3 && args.first.is_a?(Enumerable) && args[1].is_a?(Numeric) && args.last.is_a?(Numeric)
32
+ super(args.first.to_a[args[1], args.last])
33
+ else
34
+ raise "invalid arguments: [#{args.join(", ")}"
35
+ end
36
+ end
37
+
38
+ def replace_with(array, offset = 0)
39
+ array.each_with_index do |element, index|
40
+ self[offset + index] = element
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/y/lib0.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "y/lib0/binary"
4
+ require "y/lib0/buffer"
5
+ require "y/lib0/decoding"
6
+ require "y/lib0/encoding"
7
+ require "y/lib0/integer"
8
+ require "y/lib0/typed_array"
data/lib/y/sync.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Sync
5
+ MESSAGE_YJS_SYNC_STEP_1 = 0
6
+ MESSAGE_YJS_SYNC_STEP_2 = 1
7
+ MESSAGE_YJS_UPDATE = 2
8
+
9
+ # @param [Y::Lib0::Encoding::Encoder] encoder
10
+ # @param [Y::Doc] doc
11
+ def self.write_sync_step1(encoder, doc)
12
+ Y::Lib0::Encoding.write_var_uint(encoder, MESSAGE_YJS_SYNC_STEP_1)
13
+ state_vector = doc.state
14
+ Y::Lib0::Encoding.write_var_uint8_array(encoder, state_vector)
15
+ end
16
+
17
+ # @param [Y::Lib0::Encoding::Encoder] encoder
18
+ # @param [Y::Doc] doc
19
+ # @param [Array<Integer>] encoded_state_vector
20
+ def self.write_sync_step2(encoder, doc, encoded_state_vector)
21
+ Y::Lib0::Encoding.write_var_uint(encoder, MESSAGE_YJS_SYNC_STEP_2)
22
+ Y::Lib0::Encoding.write_var_uint8_array(encoder, doc.diff(encoded_state_vector))
23
+ end
24
+
25
+ # @param [Y::Lib0::Decoding::Decoder] decoder
26
+ # @param [Y::Lib0::Encoding::Encoder] encoder
27
+ # @param [Y::Doc] doc
28
+ def self.read_sync_step1(decoder, encoder, doc)
29
+ write_sync_step2(encoder, doc, Y::Lib0::Decoding.read_var_uint8_array(decoder))
30
+ end
31
+
32
+ # @param [Y::Lib0::Decoding::Decoder] decoder
33
+ # @param [Y::Doc] doc
34
+ # @param [Object] transaction_origin
35
+ # TODO: y-rb sync does not support transaction origins
36
+ def self.read_sync_step2(decoder, doc, _transaction_origin)
37
+ update = Y::Lib0::Decoding.read_var_uint8_array(decoder)
38
+ doc.sync(update)
39
+ end
40
+
41
+ # @param [Y::Lib0::Encoding::Encoder] encoder
42
+ # @param [Array<Integer>] update
43
+ def self.write_update(encoder, update)
44
+ Y::Lib0::Encoding.write_var_uint(encoder, MESSAGE_YJS_UPDATE)
45
+ Y::Lib0::Encoding.write_var_uint8_array(encoder, update)
46
+ end
47
+
48
+ # @param [Y::Lib0::Decoding::Decoder] decoder
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)
53
+ end
54
+
55
+ # @param [Y::Lib0::Decoding::Decoder] decoder
56
+ # @param [Y::Lib0::Encoding::Encoder] encoder
57
+ # @param [Y::Doc] doc
58
+ # @param [Object] transaction_origin
59
+ # TODO: y-rb sync does not support transaction origins
60
+ def self.read_sync_message(decoder, encoder, doc, transaction_origin)
61
+ message_type = Y::Lib0::Decoding.read_var_uint(decoder)
62
+
63
+ case message_type
64
+ when MESSAGE_YJS_SYNC_STEP_1
65
+ read_sync_step1(decoder, encoder, doc)
66
+ when MESSAGE_YJS_SYNC_STEP_2
67
+ read_sync_step2(decoder, doc, transaction_origin)
68
+ when MESSAGE_YJS_UPDATE
69
+ read_update(decoder, doc, transaction_origin)
70
+ else
71
+ raise "unknown message type"
72
+ end
73
+
74
+ message_type
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,7 @@
1
+ # rubocop:disable Naming/FileName
2
+ # frozen_string_literal: true
3
+
4
+ require "y/actioncable"
5
+ require "y/lib0"
6
+ require "y/sync"
7
+ # rubocop:enable Naming/FileName
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: y-rb_actioncable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Hannes Moser
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-01-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.0.4
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.0.4
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: Reliable message transmission between one or more Y.js clients.
42
+ email:
43
+ - box@hannesmoser.at
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - Rakefile
50
+ - app/assets/config/yrb_actioncable_manifest.js
51
+ - app/assets/stylesheets/yrb/actioncable/application.css
52
+ - app/helpers/yrb/actioncable/application_helper.rb
53
+ - app/jobs/yrb/actioncable/application_job.rb
54
+ - app/models/yrb/actioncable/application_record.rb
55
+ - lib/tasks/yrb/actioncable_tasks.rake
56
+ - lib/y/actioncable.rb
57
+ - lib/y/actioncable/config.rb
58
+ - lib/y/actioncable/config/abstract_builder.rb
59
+ - lib/y/actioncable/config/option.rb
60
+ - lib/y/actioncable/config/validations.rb
61
+ - lib/y/actioncable/engine.rb
62
+ - lib/y/actioncable/reliable.rb
63
+ - lib/y/actioncable/sync.rb
64
+ - lib/y/actioncable/version.rb
65
+ - lib/y/lib0.rb
66
+ - lib/y/lib0/binary.rb
67
+ - lib/y/lib0/buffer.rb
68
+ - lib/y/lib0/decoding.rb
69
+ - lib/y/lib0/encoding.rb
70
+ - lib/y/lib0/integer.rb
71
+ - lib/y/lib0/sync.rb
72
+ - lib/y/lib0/typed_array.rb
73
+ - lib/y/sync.rb
74
+ - lib/yrb-actioncable.rb
75
+ homepage: https://github.com/y-crdt/yrb-actioncable
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ allowed_push_host: https://rubygems.org
80
+ homepage_uri: https://github.com/y-crdt/yrb-actioncable
81
+ source_code_uri: https://github.com/y-crdt/yrb-actioncable
82
+ documentation_uri: https://y-crdt.github.io/yrb-actioncable/
83
+ rubygems_mfa_required: 'true'
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 2.7.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.4.1
100
+ signing_key:
101
+ specification_version: 4
102
+ summary: An ActionCable companion for Y.js clients.
103
+ test_files: []