litecable 0.4.0
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/.gitignore +40 -0
- data/.rubocop.yml +63 -0
- data/.travis.yml +7 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +128 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/circle.yml +8 -0
- data/examples/sinatra/Gemfile +16 -0
- data/examples/sinatra/Procfile +3 -0
- data/examples/sinatra/README.md +33 -0
- data/examples/sinatra/anycable +18 -0
- data/examples/sinatra/app.rb +52 -0
- data/examples/sinatra/assets/app.css +169 -0
- data/examples/sinatra/assets/cable.js +584 -0
- data/examples/sinatra/assets/reset.css +223 -0
- data/examples/sinatra/bin/anycable-go +0 -0
- data/examples/sinatra/chat.rb +39 -0
- data/examples/sinatra/config.ru +28 -0
- data/examples/sinatra/views/index.slim +8 -0
- data/examples/sinatra/views/layout.slim +15 -0
- data/examples/sinatra/views/login.slim +8 -0
- data/examples/sinatra/views/resetcss.slim +224 -0
- data/examples/sinatra/views/room.slim +68 -0
- data/lib/lite_cable.rb +29 -0
- data/lib/lite_cable/anycable.rb +62 -0
- data/lib/lite_cable/channel.rb +8 -0
- data/lib/lite_cable/channel/base.rb +165 -0
- data/lib/lite_cable/channel/registry.rb +34 -0
- data/lib/lite_cable/channel/streams.rb +56 -0
- data/lib/lite_cable/coders.rb +7 -0
- data/lib/lite_cable/coders/json.rb +19 -0
- data/lib/lite_cable/coders/raw.rb +15 -0
- data/lib/lite_cable/config.rb +18 -0
- data/lib/lite_cable/connection.rb +10 -0
- data/lib/lite_cable/connection/authorization.rb +13 -0
- data/lib/lite_cable/connection/base.rb +131 -0
- data/lib/lite_cable/connection/identification.rb +88 -0
- data/lib/lite_cable/connection/streams.rb +28 -0
- data/lib/lite_cable/connection/subscriptions.rb +108 -0
- data/lib/lite_cable/internal.rb +13 -0
- data/lib/lite_cable/logging.rb +28 -0
- data/lib/lite_cable/server.rb +27 -0
- data/lib/lite_cable/server/client_socket.rb +9 -0
- data/lib/lite_cable/server/client_socket/base.rb +163 -0
- data/lib/lite_cable/server/client_socket/subscriptions.rb +23 -0
- data/lib/lite_cable/server/heart_beat.rb +50 -0
- data/lib/lite_cable/server/middleware.rb +55 -0
- data/lib/lite_cable/server/subscribers_map.rb +67 -0
- data/lib/lite_cable/server/websocket_ext/protocols.rb +45 -0
- data/lib/lite_cable/version.rb +4 -0
- data/lib/litecable.rb +2 -0
- data/litecable.gemspec +33 -0
- metadata +256 -0
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module LiteCable
|
3
|
+
# Rack middleware to hijack sockets.
|
4
|
+
#
|
5
|
+
# Uses thread-per-connection model (thus recommended only for development and test usage).
|
6
|
+
#
|
7
|
+
# Inspired by https://github.com/ngauthier/tubesock/blob/master/lib/tubesock.rb
|
8
|
+
module Server
|
9
|
+
require "websocket"
|
10
|
+
require "lite_cable/server/subscribers_map"
|
11
|
+
require "lite_cable/server/client_socket"
|
12
|
+
require "lite_cable/server/heart_beat"
|
13
|
+
require "lite_cable/server/middleware"
|
14
|
+
|
15
|
+
class << self
|
16
|
+
attr_accessor :subscribers_map
|
17
|
+
|
18
|
+
# Broadcast encoded message to the stream
|
19
|
+
def broadcast(stream, message, coder: nil)
|
20
|
+
coder ||= LiteCable.config.coder
|
21
|
+
subscribers_map.broadcast stream, message, coder
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
self.subscribers_map = SubscribersMap.new
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module LiteCable
|
3
|
+
module Server
|
4
|
+
module ClientSocket
|
5
|
+
# Wrapper over web socket
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
7
|
+
class Base
|
8
|
+
include Logging
|
9
|
+
include Subscriptions
|
10
|
+
|
11
|
+
attr_reader :version, :active
|
12
|
+
|
13
|
+
def initialize(env, socket, version)
|
14
|
+
log(:debug, "WebSocket version #{version}")
|
15
|
+
@env = env
|
16
|
+
@socket = socket
|
17
|
+
@version = version
|
18
|
+
@active = true
|
19
|
+
|
20
|
+
@open_handlers = []
|
21
|
+
@message_handlers = []
|
22
|
+
@close_handlers = []
|
23
|
+
@error_handlers = []
|
24
|
+
|
25
|
+
@close_on_error = true
|
26
|
+
end
|
27
|
+
|
28
|
+
def prevent_close_on_error
|
29
|
+
@close_on_error = false
|
30
|
+
end
|
31
|
+
|
32
|
+
def transmit(data, type: :text)
|
33
|
+
frame = WebSocket::Frame::Outgoing::Server.new(
|
34
|
+
version: version,
|
35
|
+
data: data,
|
36
|
+
type: type
|
37
|
+
)
|
38
|
+
socket.write frame.to_s
|
39
|
+
rescue IOError, Errno::EPIPE, Errno::ETIMEDOUT => e
|
40
|
+
log(:error, "Socket send failed: #{e}")
|
41
|
+
close
|
42
|
+
end
|
43
|
+
|
44
|
+
def request
|
45
|
+
@request ||= Rack::Request.new(@env)
|
46
|
+
end
|
47
|
+
|
48
|
+
def onopen(&block)
|
49
|
+
@open_handlers << block
|
50
|
+
end
|
51
|
+
|
52
|
+
def onmessage(&block)
|
53
|
+
@message_handlers << block
|
54
|
+
end
|
55
|
+
|
56
|
+
def onclose(&block)
|
57
|
+
@close_handlers << block
|
58
|
+
end
|
59
|
+
|
60
|
+
def onerror(&block)
|
61
|
+
@error_handlers << block
|
62
|
+
end
|
63
|
+
|
64
|
+
def listen
|
65
|
+
keepalive
|
66
|
+
Thread.new do
|
67
|
+
Thread.current.abort_on_exception = true
|
68
|
+
begin
|
69
|
+
@open_handlers.each(&:call)
|
70
|
+
each_frame do |data|
|
71
|
+
@message_handlers.each do |h|
|
72
|
+
begin
|
73
|
+
h.call(data)
|
74
|
+
rescue => e
|
75
|
+
log(:error, "Socket receive failed: #{e}")
|
76
|
+
@error_handlers.each { |eh| eh.call(e, data) }
|
77
|
+
close if close_on_error
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
ensure
|
82
|
+
close
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def close
|
88
|
+
return unless @active
|
89
|
+
|
90
|
+
@close_handlers.each(&:call)
|
91
|
+
close!
|
92
|
+
|
93
|
+
@active = false
|
94
|
+
end
|
95
|
+
|
96
|
+
def closed?
|
97
|
+
@socket.closed?
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
attr_reader :socket, :close_on_error
|
103
|
+
|
104
|
+
def close!
|
105
|
+
if @socket.respond_to?(:closed?)
|
106
|
+
close_socket unless @socket.closed?
|
107
|
+
else
|
108
|
+
close_socket
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def close_socket
|
113
|
+
frame = WebSocket::Frame::Outgoing::Server.new(version: version, type: :close, code: 1000)
|
114
|
+
@socket.write(frame.to_s) if frame.supported?
|
115
|
+
@socket.close
|
116
|
+
end
|
117
|
+
|
118
|
+
def keepalive
|
119
|
+
thread = Thread.new do
|
120
|
+
Thread.current.abort_on_exception = true
|
121
|
+
loop do
|
122
|
+
sleep 5
|
123
|
+
transmit nil, type: :ping
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
onclose do
|
128
|
+
thread.kill
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# rubocop:disable Metrics/AbcSize
|
133
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
134
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
135
|
+
# rubocop:disable Metrics/MethodLength
|
136
|
+
def each_frame
|
137
|
+
framebuffer = WebSocket::Frame::Incoming::Server.new(version: version)
|
138
|
+
|
139
|
+
while IO.select([socket])
|
140
|
+
if socket.respond_to?(:recvfrom)
|
141
|
+
data, _addrinfo = socket.recvfrom(2000)
|
142
|
+
else
|
143
|
+
data, _addrinfo = socket.readpartial(2000), socket.peeraddr
|
144
|
+
end
|
145
|
+
break if data.empty?
|
146
|
+
framebuffer << data
|
147
|
+
while frame = framebuffer.next
|
148
|
+
case frame.type
|
149
|
+
when :close
|
150
|
+
return
|
151
|
+
when :text, :binary
|
152
|
+
yield frame.data
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
rescue Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::ECONNRESET, IOError, Errno::EBADF => e
|
157
|
+
log(:debug, "Socket frame error: #{e}")
|
158
|
+
nil # client disconnected or timed out
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module LiteCable
|
3
|
+
module Server
|
4
|
+
module ClientSocket
|
5
|
+
# Handle socket subscriptions
|
6
|
+
module Subscriptions
|
7
|
+
def subscribe(channel, broadcasting)
|
8
|
+
LiteCable::Server.subscribers_map
|
9
|
+
.add_subscriber(broadcasting, self, channel)
|
10
|
+
end
|
11
|
+
|
12
|
+
def unsubscribe(channel, broadcasting)
|
13
|
+
LiteCable::Server.subscribers_map
|
14
|
+
.remove_subscriber(broadcasting, self, channel)
|
15
|
+
end
|
16
|
+
|
17
|
+
def unsubscribe_from_all(channel)
|
18
|
+
LiteCable::Server.subscribers_map.remove_socket(self, channel)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module LiteCable
|
3
|
+
module Server
|
4
|
+
# Sends pings to sockets
|
5
|
+
class HeartBeat
|
6
|
+
BEAT_INTERVAL = 3
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@sockets = []
|
10
|
+
run
|
11
|
+
end
|
12
|
+
|
13
|
+
def add(socket)
|
14
|
+
@sockets << socket
|
15
|
+
end
|
16
|
+
|
17
|
+
def remove(socket)
|
18
|
+
@sockets.delete(socket)
|
19
|
+
end
|
20
|
+
|
21
|
+
def stop
|
22
|
+
@stopped = true
|
23
|
+
end
|
24
|
+
|
25
|
+
def run
|
26
|
+
Thread.new do
|
27
|
+
Thread.current.abort_on_exception = true
|
28
|
+
loop do
|
29
|
+
break if @stopped
|
30
|
+
|
31
|
+
unless @sockets.empty?
|
32
|
+
msg = ping_message Time.now.to_i
|
33
|
+
@sockets.each do |socket|
|
34
|
+
socket.transmit msg
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
sleep BEAT_INTERVAL
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def ping_message(time)
|
46
|
+
{ type: LiteCable::INTERNAL[:message_types][:ping], message: time }.to_json
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module LiteCable
|
3
|
+
module Server
|
4
|
+
require "lite_cable/server/websocket_ext/protocols"
|
5
|
+
# Rack middleware to hijack the socket
|
6
|
+
class Middleware
|
7
|
+
class HijackNotAvailable < RuntimeError; end
|
8
|
+
|
9
|
+
def initialize(_app, connection_class:)
|
10
|
+
@connection_class = connection_class
|
11
|
+
@heart_beat = HeartBeat.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
return [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] unless
|
16
|
+
env["HTTP_UPGRADE"] == 'websocket'
|
17
|
+
|
18
|
+
raise HijackNotAvailable unless env['rack.hijack']
|
19
|
+
|
20
|
+
env['rack.hijack'].call
|
21
|
+
handshake = send_handshake(env)
|
22
|
+
|
23
|
+
socket = ClientSocket::Base.new env, env['rack.hijack_io'], handshake.version
|
24
|
+
init_connection socket
|
25
|
+
init_heartbeat socket
|
26
|
+
socket.listen
|
27
|
+
[-1, {}, []]
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def send_handshake(env)
|
33
|
+
handshake = WebSocket::Handshake::Server.new
|
34
|
+
handshake.from_rack env
|
35
|
+
handshake.protocols LiteCable::INTERNAL[:protocols]
|
36
|
+
|
37
|
+
env['rack.hijack_io'].write handshake.to_s
|
38
|
+
handshake
|
39
|
+
end
|
40
|
+
|
41
|
+
def init_connection(socket)
|
42
|
+
connection = @connection_class.new(socket)
|
43
|
+
|
44
|
+
socket.onopen { connection.handle_open }
|
45
|
+
socket.onclose { connection.handle_close }
|
46
|
+
socket.onmessage { |data| connection.handle_command(data) }
|
47
|
+
end
|
48
|
+
|
49
|
+
def init_heartbeat(socket)
|
50
|
+
@heart_beat.add(socket)
|
51
|
+
socket.onclose { @heart_beat.remove(socket) }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module LiteCable
|
3
|
+
module Server
|
4
|
+
# From https://github.com/rails/rails/blob/v5.0.1/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb
|
5
|
+
class SubscribersMap
|
6
|
+
attr_reader :streams, :sockets
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@streams = Hash.new do |streams, stream_id|
|
10
|
+
streams[stream_id] = Hash.new { |channels, channel_id| channels[channel_id] = [] }
|
11
|
+
end
|
12
|
+
@sockets = Hash.new { |h, k| h[k] = [] }
|
13
|
+
@sync = Mutex.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def add_subscriber(stream, socket, channel)
|
17
|
+
@sync.synchronize do
|
18
|
+
@streams[stream][channel] << socket
|
19
|
+
@sockets[socket] << [channel, stream]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def remove_subscriber(stream, socket, channel)
|
24
|
+
@sync.synchronize do
|
25
|
+
@streams[stream][channel].delete(socket)
|
26
|
+
@sockets[socket].delete([channel, stream])
|
27
|
+
cleanup stream, socket, channel
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def remove_socket(socket, channel)
|
32
|
+
list = @sync.synchronize do
|
33
|
+
return unless @sockets.key?(socket)
|
34
|
+
@sockets[socket].dup
|
35
|
+
end
|
36
|
+
|
37
|
+
list.each do |(channel_id, stream)|
|
38
|
+
remove_subscriber(stream, socket, channel) if channel == channel_id
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def broadcast(stream, message, coder)
|
43
|
+
list = @sync.synchronize do
|
44
|
+
return unless @streams.key?(stream)
|
45
|
+
@streams[stream].to_a
|
46
|
+
end
|
47
|
+
|
48
|
+
list.each do |(channel_id, sockets)|
|
49
|
+
cmessage = channel_message(channel_id, message, coder)
|
50
|
+
sockets.each { |s| s.transmit cmessage }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def cleanup(stream, socket, channel)
|
57
|
+
@streams[stream].delete(channel) if @streams[stream][channel].empty?
|
58
|
+
@streams.delete(stream) if @streams[stream].empty?
|
59
|
+
@sockets.delete(socket) if @sockets[socket].empty?
|
60
|
+
end
|
61
|
+
|
62
|
+
def channel_message(channel_id, message, coder)
|
63
|
+
coder.encode(identifier: channel_id, message: message)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# Add missing protocols support to websocket-ruby
|
3
|
+
module WebSocketExt
|
4
|
+
module Protocols # :nodoc:
|
5
|
+
module Handshake # :nodoc:
|
6
|
+
# Specify server protocols
|
7
|
+
def protocols(values)
|
8
|
+
@protocols = values
|
9
|
+
end
|
10
|
+
|
11
|
+
# Return matching protocol
|
12
|
+
def protocol
|
13
|
+
return @protocol if instance_variable_defined?(:@protocol)
|
14
|
+
protos = @headers['sec-websocket-protocol']
|
15
|
+
|
16
|
+
return @protocol = nil unless protos
|
17
|
+
@protocol = begin
|
18
|
+
protos = protos.split(/ *, */) if protos.is_a?(String)
|
19
|
+
protos.find { |p| @protocols.include?(p) }
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
module Handler # :nodoc:
|
25
|
+
def handshake_keys
|
26
|
+
return super unless @handshake.protocol
|
27
|
+
super + [
|
28
|
+
[
|
29
|
+
'Sec-WebSocket-Protocol',
|
30
|
+
@handshake.protocol
|
31
|
+
]
|
32
|
+
]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
WebSocket::Handshake::Server.include WebSocketExt::Protocols::Handshake
|
39
|
+
[
|
40
|
+
WebSocket::Handshake::Handler::Server04,
|
41
|
+
WebSocket::Handshake::Handler::Server75,
|
42
|
+
WebSocket::Handshake::Handler::Server76
|
43
|
+
].each do |handler|
|
44
|
+
handler.prepend WebSocketExt::Protocols::Handler
|
45
|
+
end
|