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 +7 -0
- data/README.md +28 -0
- data/Rakefile +47 -0
- data/app/assets/config/yrb_actioncable_manifest.js +1 -0
- data/app/assets/stylesheets/yrb/actioncable/application.css +15 -0
- data/app/helpers/yrb/actioncable/application_helper.rb +8 -0
- data/app/jobs/yrb/actioncable/application_job.rb +8 -0
- data/app/models/yrb/actioncable/application_record.rb +9 -0
- data/lib/tasks/yrb/actioncable_tasks.rake +5 -0
- data/lib/y/actioncable/config/abstract_builder.rb +29 -0
- data/lib/y/actioncable/config/option.rb +85 -0
- data/lib/y/actioncable/config/validations.rb +13 -0
- data/lib/y/actioncable/config.rb +46 -0
- data/lib/y/actioncable/engine.rb +15 -0
- data/lib/y/actioncable/reliable.rb +199 -0
- data/lib/y/actioncable/sync.rb +122 -0
- data/lib/y/actioncable/version.rb +7 -0
- data/lib/y/actioncable.rb +13 -0
- data/lib/y/lib0/binary.rb +76 -0
- data/lib/y/lib0/buffer.rb +11 -0
- data/lib/y/lib0/decoding.rb +61 -0
- data/lib/y/lib0/encoding.rb +142 -0
- data/lib/y/lib0/integer.rb +12 -0
- data/lib/y/lib0/sync.rb +16 -0
- data/lib/y/lib0/typed_array.rb +45 -0
- data/lib/y/lib0.rb +8 -0
- data/lib/y/sync.rb +77 -0
- data/lib/yrb-actioncable.rb +7 -0
- metadata +103 -0
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,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,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,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,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,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
|
data/lib/y/lib0/sync.rb
ADDED
@@ -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
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
|
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: []
|