y-rb_actioncable 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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: []