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 +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: []
|