pakyow-realtime 0.10.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/pakyow-realtime/CHANGELOG.md +3 -0
- data/pakyow-realtime/LICENSE +20 -0
- data/pakyow-realtime/README.md +164 -0
- data/pakyow-realtime/lib/pakyow-realtime.rb +21 -0
- data/pakyow-realtime/lib/pakyow-realtime/config.rb +22 -0
- data/pakyow-realtime/lib/pakyow-realtime/connection.rb +18 -0
- data/pakyow-realtime/lib/pakyow-realtime/context.rb +60 -0
- data/pakyow-realtime/lib/pakyow-realtime/delegate.rb +95 -0
- data/pakyow-realtime/lib/pakyow-realtime/exceptions.rb +6 -0
- data/pakyow-realtime/lib/pakyow-realtime/ext/request.rb +10 -0
- data/pakyow-realtime/lib/pakyow-realtime/helpers.rb +33 -0
- data/pakyow-realtime/lib/pakyow-realtime/hooks.rb +30 -0
- data/pakyow-realtime/lib/pakyow-realtime/message_handler.rb +57 -0
- data/pakyow-realtime/lib/pakyow-realtime/message_handlers/call_route.rb +33 -0
- data/pakyow-realtime/lib/pakyow-realtime/message_handlers/ping.rb +8 -0
- data/pakyow-realtime/lib/pakyow-realtime/redis_subscription.rb +58 -0
- data/pakyow-realtime/lib/pakyow-realtime/registries/redis_registry.rb +94 -0
- data/pakyow-realtime/lib/pakyow-realtime/registries/simple_registry.rb +40 -0
- data/pakyow-realtime/lib/pakyow-realtime/websocket.rb +188 -0
- metadata +132 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4444748188cdd7131282b72c485e4560c9c1b1a5
|
4
|
+
data.tar.gz: 7a1a719e074da37c83369b4cd4f0ee0151d0d0e5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 10c2680a1c78d956357641ae71e57b96d8f2addd024558fe7c7d7edd07927cdd15e703559e5b13ac47354abc0edaef38bdc3fab87bea0cd6becba730e52694b0
|
7
|
+
data.tar.gz: 3c128c335c1fae854c751439a7b1b8848c976e31dda66052a68890c6d4e6017d5ca18ac021f054669c4156ca0a6dc79b2a654a22f1d69d7812aae64da78f4eed
|
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2015 Bryan Powell
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
@@ -0,0 +1,164 @@
|
|
1
|
+
# pakyow-realtime
|
2
|
+
|
3
|
+
Brings realtime capabilities to Pakyow by creating a pub/sub
|
4
|
+
connection between client and server using WebSockets.
|
5
|
+
|
6
|
+
## Overview
|
7
|
+
|
8
|
+
Clients can be subscribed to channels. Realtime keeps track of what channels a
|
9
|
+
client has been subscribed to and tracks subscriptions across requests. Routes
|
10
|
+
can push messages down channels to one or more subscribed clients through an
|
11
|
+
established WebSocket.
|
12
|
+
|
13
|
+
WebSockets are established by hijacking an HTTP request. Once hijacked, the
|
14
|
+
WebSocket is forked into an async object via the highly performant [Concurrent
|
15
|
+
Ruby](https://github.com/ruby-concurrency/concurrent-ruby) library. This
|
16
|
+
approach allows each app instance to manage its own WebSockets while still
|
17
|
+
serving normal requests.
|
18
|
+
|
19
|
+
In addition to pushing messages from the server to the client, the client can
|
20
|
+
send messages to the server. For example, out of the box Realtime supports
|
21
|
+
calling routes over a WebSocket, with the response being pushed down once
|
22
|
+
processing is complete.
|
23
|
+
|
24
|
+
## Establishing a WebSocket connection to the server.
|
25
|
+
|
26
|
+
Using the native Javascript `WebSocket` support in modern browsers, simply open
|
27
|
+
a connection to your app. You'll need to include the connection id associated
|
28
|
+
with your client, which tells Realtime what channels the connection should
|
29
|
+
listen to. This connection id is automatically set on the `body` tag in a
|
30
|
+
rendered view.
|
31
|
+
|
32
|
+
Here's some example Javascript code that establishes a WebSocket connection:
|
33
|
+
|
34
|
+
```javascript
|
35
|
+
var wsUrl = '';
|
36
|
+
|
37
|
+
var host = window.location.hostname;
|
38
|
+
var port = window.location.port;
|
39
|
+
|
40
|
+
if (window.location.protocol === 'http:') {
|
41
|
+
wsUrl += 'ws://';
|
42
|
+
} else if (window.location.protocol === 'https:') {
|
43
|
+
wsUrl += 'wss://';
|
44
|
+
}
|
45
|
+
|
46
|
+
wsUrl += host;
|
47
|
+
|
48
|
+
if (port) {
|
49
|
+
wsUrl += ':' + port;
|
50
|
+
}
|
51
|
+
|
52
|
+
var conn = document.getElementsByTagName('body')[0].getAttribute('data-socket-connection-id');
|
53
|
+
wsUrl += '/?socket_connection_id=' + conn;
|
54
|
+
|
55
|
+
console.log('Opening connection with id: ' + conn);
|
56
|
+
window.socket = new WebSocket(wsUrl);
|
57
|
+
|
58
|
+
window.socket.onopen = function (event) {
|
59
|
+
console.log('Socket opened.');
|
60
|
+
};
|
61
|
+
```
|
62
|
+
|
63
|
+
A full example is available in the [example app](https://github.com/bryanp/pakyow-example-realtime).
|
64
|
+
|
65
|
+
### Security
|
66
|
+
|
67
|
+
The connection id is an important security feature of Realtime. Channel
|
68
|
+
subscriptions are managed with a socket digest, generated from a key and
|
69
|
+
connection id. The key is stored in the session object for a single client. If a
|
70
|
+
socket is established with an incorrect connection id for the current client,
|
71
|
+
the connection won't receive messages directed at that client (although the
|
72
|
+
connection will appear to have been properly established) because the digest
|
73
|
+
generated will also be incorrect.
|
74
|
+
|
75
|
+
## Subscribing a client to a channel.
|
76
|
+
|
77
|
+
From a route, simply call the `subscribe` method on the socket:
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
socket.subscribe(:chan1)
|
81
|
+
```
|
82
|
+
|
83
|
+
To unsubscribe, call `unsubscribe`:
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
socket.unsubscribe(:chan1)
|
87
|
+
```
|
88
|
+
|
89
|
+
## Pushing messages through a channel to one or more clients.
|
90
|
+
|
91
|
+
To push a message down a channel, call `push` from a route:
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
socket.push({ foo: 'bar' }, :chan1)
|
95
|
+
```
|
96
|
+
|
97
|
+
The first argument is the message and the second argument is a single channel or
|
98
|
+
list of channels to push the message through. Each client subscribed to the
|
99
|
+
channel will receive the message as a JSON object.
|
100
|
+
|
101
|
+
## Calling routes from the client.
|
102
|
+
|
103
|
+
Realtime also provides a mechanism for round trip client -> server -> client
|
104
|
+
communication. Bundled with the library is a handler for calling routes through
|
105
|
+
a WebSocket. An example of this is included in the example app.
|
106
|
+
|
107
|
+
## Running in production.
|
108
|
+
|
109
|
+
Redis is leveraged in production to handle:
|
110
|
+
|
111
|
+
1. Tracking what clients are subscribed to what channels.
|
112
|
+
2. Communicating between WebSocket connections contained on various app
|
113
|
+
instances. Redis must be used to scale beyond a single app instance.
|
114
|
+
|
115
|
+
The Redis registry will automatically be used when running in a `production`
|
116
|
+
environment. But, in case you ever need to configure manually, add the following
|
117
|
+
code to the appropriate `configure` block in `app/setup.rb`:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
realtime.registry = Pakyow::Realtime::RedisRegistry
|
121
|
+
```
|
122
|
+
|
123
|
+
To configure the Redis connection itself, configure like this:
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
realtime.redis = { url: 'redis://localhost:6379' }
|
127
|
+
```
|
128
|
+
|
129
|
+
## Defining custom message handlers.
|
130
|
+
|
131
|
+
Custom handlers can be defined for letting clients tell the server to do specific
|
132
|
+
things. Check out the bundled [`call_route` handler](https://github.com/pakyow/pakyow/blob/master/pakyow-realtime/lib/pakyow-realtime/message_handlers/call_route.rb), with a usage example in the
|
133
|
+
[example app](https://github.com/bryanp/pakyow-example-realtime).
|
134
|
+
|
135
|
+
# Download
|
136
|
+
|
137
|
+
The latest version of Pakyow Realtime can be installed with RubyGems:
|
138
|
+
|
139
|
+
```
|
140
|
+
gem install pakyow-realtime
|
141
|
+
```
|
142
|
+
|
143
|
+
Source code can be downloaded as part of the Pakyow project on Github:
|
144
|
+
|
145
|
+
- https://github.com/pakyow/pakyow/tree/master/pakyow-realtime
|
146
|
+
|
147
|
+
# License
|
148
|
+
|
149
|
+
Pakyow Realtime is released free and open-source under the [MIT
|
150
|
+
License](http://opensource.org/licenses/MIT).
|
151
|
+
|
152
|
+
# Support
|
153
|
+
|
154
|
+
Documentation is available here:
|
155
|
+
|
156
|
+
- http://pakyow.org/docs/realtime
|
157
|
+
|
158
|
+
Found a bug? Tell us about it here:
|
159
|
+
|
160
|
+
- https://github.com/pakyow/pakyow/issues
|
161
|
+
|
162
|
+
We'd love to have you in the community:
|
163
|
+
|
164
|
+
- http://pakyow.org/get-involved
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
require 'pakyow-support'
|
5
|
+
require 'pakyow-core'
|
6
|
+
|
7
|
+
require_relative 'pakyow-realtime/helpers'
|
8
|
+
require_relative 'pakyow-realtime/hooks'
|
9
|
+
require_relative 'pakyow-realtime/context'
|
10
|
+
require_relative 'pakyow-realtime/delegate'
|
11
|
+
require_relative 'pakyow-realtime/registries/simple_registry'
|
12
|
+
require_relative 'pakyow-realtime/registries/redis_registry'
|
13
|
+
require_relative 'pakyow-realtime/redis_subscription'
|
14
|
+
require_relative 'pakyow-realtime/websocket'
|
15
|
+
require_relative 'pakyow-realtime/config'
|
16
|
+
require_relative 'pakyow-realtime/exceptions'
|
17
|
+
require_relative 'pakyow-realtime/message_handler'
|
18
|
+
require_relative 'pakyow-realtime/message_handlers/call_route'
|
19
|
+
require_relative 'pakyow-realtime/message_handlers/ping'
|
20
|
+
|
21
|
+
require_relative 'pakyow-realtime/ext/request'
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require_relative 'registries/simple_registry'
|
2
|
+
require_relative 'registries/redis_registry'
|
3
|
+
|
4
|
+
Pakyow::Config.register(:realtime) { |config|
|
5
|
+
# The registry to use when keeping up with connections.
|
6
|
+
config.opt :registry, Pakyow::Realtime::SimpleRegistry
|
7
|
+
|
8
|
+
# The Redis config hash.
|
9
|
+
config.opt :redis, url: 'redis://127.0.0.1:6379'
|
10
|
+
|
11
|
+
# The key used to keep track of channels in Redis.
|
12
|
+
config.opt :redis_key, 'pw:channels'
|
13
|
+
|
14
|
+
# Whether or not realtime should be enabled.
|
15
|
+
config.opt :enabled, true
|
16
|
+
}.env(:development) { |opts|
|
17
|
+
opts.registry = Pakyow::Realtime::SimpleRegistry
|
18
|
+
}.env(:staging) { |opts|
|
19
|
+
opts.registry = Pakyow::Realtime::RedisRegistry
|
20
|
+
}.env(:production) { |opts|
|
21
|
+
opts.registry = Pakyow::Realtime::RedisRegistry
|
22
|
+
}
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require_relative 'config'
|
2
|
+
|
3
|
+
module Pakyow
|
4
|
+
module Realtime
|
5
|
+
# Represents a realtime connection (e.g. websocket).
|
6
|
+
#
|
7
|
+
# @api private
|
8
|
+
class Connection
|
9
|
+
def delegate
|
10
|
+
Delegate.instance
|
11
|
+
end
|
12
|
+
|
13
|
+
def logger
|
14
|
+
Pakyow.logger
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require_relative 'websocket'
|
2
|
+
require_relative 'config'
|
3
|
+
|
4
|
+
module Pakyow
|
5
|
+
module Realtime
|
6
|
+
# Deals with realtime connections in context of an app. Instances are
|
7
|
+
# returned by the `socket` helper method during routing.
|
8
|
+
#
|
9
|
+
# @api public
|
10
|
+
class Context
|
11
|
+
# @api private
|
12
|
+
def initialize(app)
|
13
|
+
@app = app
|
14
|
+
end
|
15
|
+
|
16
|
+
# Subscribe the current session's connection to one or more channels.
|
17
|
+
#
|
18
|
+
# @api public
|
19
|
+
def subscribe(*channels)
|
20
|
+
channels = Array.ensure(channels).flatten
|
21
|
+
fail ArgumentError if channels.empty?
|
22
|
+
|
23
|
+
delegate.subscribe(
|
24
|
+
@app.socket_digest(@app.socket_connection_id),
|
25
|
+
channels
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Unsubscribe the current session's connection to one or more channels.
|
30
|
+
#
|
31
|
+
# @api public
|
32
|
+
def unsubscribe(*channels)
|
33
|
+
channels = Array.ensure(channels).flatten
|
34
|
+
fail ArgumentError if channels.empty?
|
35
|
+
|
36
|
+
delegate.unsubscribe(
|
37
|
+
@app.socket_digest(@app.socket_connection_id),
|
38
|
+
channels
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Push a message down one or more channels.
|
43
|
+
#
|
44
|
+
# @api public
|
45
|
+
def push(msg, *channels)
|
46
|
+
channels = Array.ensure(channels).flatten
|
47
|
+
fail ArgumentError if channels.empty?
|
48
|
+
|
49
|
+
delegate.push(msg, channels)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns an instance of the connection delegate.
|
53
|
+
#
|
54
|
+
# @api private
|
55
|
+
def delegate
|
56
|
+
Delegate.instance
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module Pakyow
|
2
|
+
module Realtime
|
3
|
+
# A singleton for delegating socket traffic using the configured registry.
|
4
|
+
#
|
5
|
+
# @api private
|
6
|
+
class Delegate
|
7
|
+
include Singleton
|
8
|
+
|
9
|
+
attr_reader :registry, :connections, :channels
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@registry = Config.realtime.registry.instance
|
13
|
+
|
14
|
+
@connections = {}
|
15
|
+
@channels = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
# Registers a websocket instance with a unique key.
|
19
|
+
def register(key, connection)
|
20
|
+
@connections[key] = connection
|
21
|
+
|
22
|
+
channels = registry.channels_for_key(key)
|
23
|
+
|
24
|
+
channels.each do |channel|
|
25
|
+
next if connection.nil?
|
26
|
+
@channels[channel] ||= []
|
27
|
+
|
28
|
+
next if @channels[channel].include?(connection)
|
29
|
+
@channels[channel] << connection
|
30
|
+
end
|
31
|
+
|
32
|
+
registry.subscribe_for_propagation(channels) if registry.propagates?
|
33
|
+
end
|
34
|
+
|
35
|
+
# Unregisters a connection by its key.
|
36
|
+
def unregister(key)
|
37
|
+
registry.unregister_key(key)
|
38
|
+
|
39
|
+
connection = @connections.delete(key)
|
40
|
+
@channels.each do |_channel, connections|
|
41
|
+
connections.delete(connection)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Subscribes a websocket identified by its key to one or more channels.
|
46
|
+
def subscribe(key, channels)
|
47
|
+
registry.subscribe_to_channels_for_key(channels, key)
|
48
|
+
|
49
|
+
# register the connection again since we've added channels
|
50
|
+
register(key, @connections[key])
|
51
|
+
end
|
52
|
+
|
53
|
+
# Unsubscribes a websocket identified by its key to one or more channels.
|
54
|
+
def unsubscribe(key, channels)
|
55
|
+
registry.unsubscribe_to_channels_for_key(channels, key)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Pushes a message down channels from server to client.
|
59
|
+
def push(message, channels)
|
60
|
+
if registry.propagates? && !propagated?(message)
|
61
|
+
return propagate(message, channels)
|
62
|
+
elsif propagated?(message)
|
63
|
+
message.delete(:__propagated)
|
64
|
+
end
|
65
|
+
|
66
|
+
# push to this instances connections
|
67
|
+
channels.each do |channel_query|
|
68
|
+
connections_for_channel(channel_query).each_pair do |channel, conns|
|
69
|
+
conns.each do |connection|
|
70
|
+
connection.push(payload: message, channel: channel)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def propagate(message, channels)
|
79
|
+
registry.propagate(message, channels)
|
80
|
+
end
|
81
|
+
|
82
|
+
def propagated?(message)
|
83
|
+
message.include?(:__propagated)
|
84
|
+
end
|
85
|
+
|
86
|
+
def connections_for_channel(channel_query)
|
87
|
+
regexp = Regexp.new("^#{channel_query.to_s.gsub('*', '([^;]*)')}$")
|
88
|
+
|
89
|
+
@channels.select { |channel, _conns|
|
90
|
+
channel.match(regexp)
|
91
|
+
}
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Pakyow
|
2
|
+
module Helpers
|
3
|
+
# Returns a working realtime context for the current app context.
|
4
|
+
#
|
5
|
+
# @api public
|
6
|
+
def socket
|
7
|
+
Realtime::Context.new(self)
|
8
|
+
end
|
9
|
+
|
10
|
+
# Returns the session's unique realtime key.
|
11
|
+
#
|
12
|
+
# @api private
|
13
|
+
def socket_key
|
14
|
+
return params[:socket_key] if params[:socket_key]
|
15
|
+
session[:socket_key] ||= SecureRandom.hex(32)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Returns the unique connection id for this request lifecycle.
|
19
|
+
#
|
20
|
+
# @api private
|
21
|
+
def socket_connection_id
|
22
|
+
return params[:socket_connection_id] if params[:socket_connection_id]
|
23
|
+
@socket_connection_id ||= SecureRandom.hex(32)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns a digest created from the connection id and socket_key.
|
27
|
+
#
|
28
|
+
# @api private
|
29
|
+
def socket_digest(socket_connection_id)
|
30
|
+
Digest::SHA1.hexdigest("--#{socket_key}--#{socket_connection_id}--")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
Pakyow::App.before :route do
|
2
|
+
# we want to hijack websocket requests
|
3
|
+
#
|
4
|
+
if req.env['HTTP_UPGRADE'] == 'websocket'
|
5
|
+
if Pakyow::Config.realtime.enabled
|
6
|
+
socket_connection_id = params[:socket_connection_id]
|
7
|
+
socket_digest = socket_digest(socket_connection_id)
|
8
|
+
|
9
|
+
conn = Pakyow::Realtime::Websocket.new(req, socket_digest)
|
10
|
+
|
11
|
+
# register the connection with a unique key
|
12
|
+
Pakyow::Realtime::Delegate.instance.register(socket_digest, conn)
|
13
|
+
end
|
14
|
+
|
15
|
+
halt
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
Pakyow::App.after :process do
|
20
|
+
# mixin the socket connection id into the body tag
|
21
|
+
# this id is used by pakyow.js to idenfity itself with the server
|
22
|
+
#
|
23
|
+
if response.header['Content-Type'] == 'text/html' && Pakyow::Config.realtime.enabled
|
24
|
+
body = response.body[0]
|
25
|
+
next if body.nil?
|
26
|
+
|
27
|
+
mixin = '<body data-socket-connection-id="' + socket_connection_id + '"'
|
28
|
+
body.gsub!(/<body/, mixin)
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require_relative 'exceptions'
|
2
|
+
|
3
|
+
module Pakyow
|
4
|
+
module Realtime
|
5
|
+
# Convenience method for registering a new message handler.
|
6
|
+
#
|
7
|
+
# @api public
|
8
|
+
def self.handler(name, &block)
|
9
|
+
MessageHandler.register(name, &block)
|
10
|
+
end
|
11
|
+
|
12
|
+
# A message handler registry. Handlers subscribe to some action and handle
|
13
|
+
# incoming messages for that action, returning a response.
|
14
|
+
#
|
15
|
+
# @api private
|
16
|
+
class MessageHandler
|
17
|
+
# Registers a handler for some action name.
|
18
|
+
#
|
19
|
+
# @api private
|
20
|
+
def self.register(name, &block)
|
21
|
+
handlers[name.to_sym] = block
|
22
|
+
end
|
23
|
+
|
24
|
+
# Calls a handler for a received websocket message.
|
25
|
+
#
|
26
|
+
# @api private
|
27
|
+
def self.handle(message, session)
|
28
|
+
id = message.fetch('id') {
|
29
|
+
fail ArgumentError, "Expected message to contain key 'id'"
|
30
|
+
}
|
31
|
+
|
32
|
+
action = message.fetch('action') {
|
33
|
+
fail ArgumentError, "Expected message to contain key 'action'"
|
34
|
+
}
|
35
|
+
|
36
|
+
handler = handlers.fetch(action.to_sym) {
|
37
|
+
fail MissingMessageHandler, "No message handler named #{action}"
|
38
|
+
}
|
39
|
+
|
40
|
+
handler.call(message, session, id: id)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Resets the message handlers.
|
44
|
+
#
|
45
|
+
# @api private
|
46
|
+
def self.reset
|
47
|
+
@handlers = nil
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def self.handlers
|
53
|
+
@handlers ||= {}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# Calls an app route and returns a response, just like an HTTP request!
|
2
|
+
#
|
3
|
+
Pakyow::Realtime.handler :'call-route' do |message, session, response|
|
4
|
+
path, qs = message['uri'].split('?')
|
5
|
+
path_parts = path.split('/')
|
6
|
+
path_parts[-1] += '.json'
|
7
|
+
uri = [path_parts.join('/'), qs].join('?')
|
8
|
+
|
9
|
+
env = Rack::MockRequest.env_for(uri, method: message['method'])
|
10
|
+
env['pakyow.socket'] = true
|
11
|
+
env['pakyow.data'] = message['input']
|
12
|
+
env['rack.session'] = session
|
13
|
+
|
14
|
+
# TODO: in production we want to push the message to a queue and
|
15
|
+
# let the next available app instance pick it up, rather than
|
16
|
+
# the current instance to handle all traffic on this socket
|
17
|
+
app = Pakyow.app.dup
|
18
|
+
res = app.process(env)
|
19
|
+
|
20
|
+
container = message['container']
|
21
|
+
|
22
|
+
if container
|
23
|
+
composer = app.presenter.composer
|
24
|
+
body = composer.container(container.to_sym).includes(composer.partials).to_s
|
25
|
+
else
|
26
|
+
body = res[2].body
|
27
|
+
end
|
28
|
+
|
29
|
+
response[:status] = res[0]
|
30
|
+
response[:headers] = res[1]
|
31
|
+
response[:body] = body
|
32
|
+
response
|
33
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'concurrent'
|
3
|
+
|
4
|
+
module Pakyow
|
5
|
+
module Realtime
|
6
|
+
# Manages channel subscriptions for this application instance's WebSockets.
|
7
|
+
#
|
8
|
+
# @api private
|
9
|
+
class RedisSubscription
|
10
|
+
include Concurrent::Async
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@redis = ::Redis.new(Config.realtime.redis)
|
14
|
+
@channels = []
|
15
|
+
|
16
|
+
ObjectSpace.define_finalizer(self, self.class.finalize)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.finalize
|
20
|
+
-> {
|
21
|
+
unsubscribe
|
22
|
+
@redis.quit
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
def subscribe(channels)
|
27
|
+
return if channels.empty?
|
28
|
+
@channels = channels
|
29
|
+
|
30
|
+
run
|
31
|
+
end
|
32
|
+
|
33
|
+
def unsubscribe
|
34
|
+
return if @channels.empty?
|
35
|
+
@redis.unsubscribe(*@channels)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def run
|
41
|
+
@redis.subscribe(*@channels) do |on|
|
42
|
+
on.message do |channel, msg|
|
43
|
+
msg = JSON.parse(msg)
|
44
|
+
|
45
|
+
if msg.is_a?(Hash)
|
46
|
+
msg[:__propagated] = true
|
47
|
+
elsif msg.is_a?(Array)
|
48
|
+
msg << :__propagated
|
49
|
+
end
|
50
|
+
|
51
|
+
context = Pakyow::Realtime::Context.new(Pakyow.app)
|
52
|
+
context.push(msg, channel)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'redis'
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
require_relative '../redis_subscription'
|
6
|
+
|
7
|
+
module Pakyow
|
8
|
+
module Realtime
|
9
|
+
def self.redis
|
10
|
+
$redis ||= Redis.new(Config.realtime.redis)
|
11
|
+
end
|
12
|
+
# Manages WebSocket connections and their subscriptions in Redis.
|
13
|
+
#
|
14
|
+
# This is the default registry in production systems and is required in
|
15
|
+
# deployments with more than one app instance.
|
16
|
+
#
|
17
|
+
# @api private
|
18
|
+
class RedisRegistry
|
19
|
+
include Singleton
|
20
|
+
|
21
|
+
attr_reader :subscriber
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
@channels = []
|
25
|
+
end
|
26
|
+
|
27
|
+
def channels_for_key(key)
|
28
|
+
channels(key)
|
29
|
+
end
|
30
|
+
|
31
|
+
def unregister_key(key)
|
32
|
+
Pakyow::Realtime.redis.hdel(channel_key, key)
|
33
|
+
end
|
34
|
+
|
35
|
+
def subscribe_to_channels_for_key(channels, key)
|
36
|
+
new_channels = channels(key).concat(Array.ensure(channels)).uniq
|
37
|
+
Pakyow::Realtime.redis.hset(channel_key, key, new_channels.to_json)
|
38
|
+
|
39
|
+
@channels.concat(channels).uniq!
|
40
|
+
resubscribe
|
41
|
+
end
|
42
|
+
|
43
|
+
def unsubscribe_to_channels_for_key(channels, key)
|
44
|
+
new_channels = channels(key) - Array.ensure(channels)
|
45
|
+
Pakyow::Realtime.redis.hset(channel_key, key, new_channels.to_json)
|
46
|
+
|
47
|
+
channels.each { |channel| @channels.delete(channel) }
|
48
|
+
resubscribe
|
49
|
+
end
|
50
|
+
|
51
|
+
def propagates?
|
52
|
+
true
|
53
|
+
end
|
54
|
+
|
55
|
+
def propagate(message, channels)
|
56
|
+
message_json = message.to_json
|
57
|
+
|
58
|
+
channels.each do |channel|
|
59
|
+
Pakyow::Realtime.redis.publish(channel, message_json)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def subscribe_for_propagation(channels)
|
64
|
+
@channels.concat(channels).uniq!
|
65
|
+
resubscribe
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
# Terminates the current subscriber and creates a new
|
71
|
+
# subscriber with the current channels.
|
72
|
+
def resubscribe
|
73
|
+
if @subscriber
|
74
|
+
@subscriber.async.unsubscribe
|
75
|
+
else
|
76
|
+
@subscriber = RedisSubscription.new
|
77
|
+
end
|
78
|
+
|
79
|
+
@subscriber.async.subscribe(@channels)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns the key used to store channels.
|
83
|
+
def channel_key
|
84
|
+
Config.realtime.redis_key
|
85
|
+
end
|
86
|
+
|
87
|
+
# Returns the channels for a specific key, or all channels.
|
88
|
+
def channels(key)
|
89
|
+
value = Pakyow::Realtime.redis.hget(channel_key, key)
|
90
|
+
(value ? JSON.parse(value) : []).map(&:to_sym)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
|
3
|
+
module Pakyow
|
4
|
+
module Realtime
|
5
|
+
# Manages WebSocket connections and their subscriptions in memory.
|
6
|
+
#
|
7
|
+
# Intended only for use in development or single app-instance deployments.
|
8
|
+
#
|
9
|
+
# @api private
|
10
|
+
class SimpleRegistry
|
11
|
+
include Singleton
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@channels = {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def channels_for_key(key)
|
18
|
+
@channels.fetch(key, [])
|
19
|
+
end
|
20
|
+
|
21
|
+
def unregister_key(key)
|
22
|
+
@channels.delete(key)
|
23
|
+
end
|
24
|
+
|
25
|
+
def subscribe_to_channels_for_key(channels, key)
|
26
|
+
@channels[key] ||= []
|
27
|
+
@channels[key].concat(Array.ensure(channels.map(&:to_sym))).uniq!
|
28
|
+
end
|
29
|
+
|
30
|
+
def unsubscribe_to_channels_for_key(channels, key)
|
31
|
+
@channels[key] ||= []
|
32
|
+
@channels[key] = @channels[key] - Array.ensure(channels.map(&:to_sym))
|
33
|
+
end
|
34
|
+
|
35
|
+
def propagates?
|
36
|
+
false
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
require 'concurrent'
|
2
|
+
require 'websocket_parser'
|
3
|
+
|
4
|
+
require_relative 'connection'
|
5
|
+
|
6
|
+
module Pakyow
|
7
|
+
module Realtime
|
8
|
+
# Hijacks a request, performs the handshake, and creates an async object
|
9
|
+
# for handling incoming and outgoing messages in an asynchronous manner.
|
10
|
+
#
|
11
|
+
# @api private
|
12
|
+
class Websocket < Connection
|
13
|
+
attr_reader :parser, :socket, :key
|
14
|
+
|
15
|
+
@event_handlers = {}
|
16
|
+
|
17
|
+
def initialize(req, key)
|
18
|
+
@req = req
|
19
|
+
@key = key
|
20
|
+
|
21
|
+
@handshake = handshake!(req)
|
22
|
+
@socket = hijack!(req)
|
23
|
+
|
24
|
+
handle_handshake
|
25
|
+
end
|
26
|
+
|
27
|
+
def shutdown
|
28
|
+
delegate.unregister(@key)
|
29
|
+
self.class.handle_event(:leave, @req)
|
30
|
+
|
31
|
+
@socket.close if @socket && !@socket.closed?
|
32
|
+
@shutdown = true
|
33
|
+
|
34
|
+
@reader = nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def shutdown?
|
38
|
+
@shutdown == true
|
39
|
+
end
|
40
|
+
|
41
|
+
def push(msg)
|
42
|
+
json = JSON.pretty_generate(msg)
|
43
|
+
logger.debug "(ws.#{@key}) sending message: #{json}\n"
|
44
|
+
WebSocket::Message.new(json).write(@socket)
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.on(event, &block)
|
48
|
+
(@event_handlers[event.to_sym] ||= []) << block
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def handshake!(req)
|
54
|
+
WebSocket::ClientHandshake.new(:get, req.url, handshake_headers(req))
|
55
|
+
end
|
56
|
+
|
57
|
+
def hijack!(req)
|
58
|
+
if req.env['rack.hijack']
|
59
|
+
req.env['rack.hijack'].call
|
60
|
+
return req.env['rack.hijack_io']
|
61
|
+
else
|
62
|
+
logger.info "there's no socket to hijack :("
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def handshake_headers(req)
|
67
|
+
{
|
68
|
+
'Upgrade' => req.env['HTTP_UPGRADE'],
|
69
|
+
'Sec-WebSocket-Version' => req.env['HTTP_SEC_WEBSOCKET_VERSION'],
|
70
|
+
'Sec-Websocket-Key' => req.env['HTTP_SEC_WEBSOCKET_KEY']
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
def handle_handshake
|
75
|
+
return if @socket.nil?
|
76
|
+
|
77
|
+
if @handshake.valid?
|
78
|
+
accept_handshake
|
79
|
+
setup
|
80
|
+
else
|
81
|
+
fail_handshake
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def accept_handshake
|
86
|
+
response = @handshake.accept_response
|
87
|
+
response.render(@socket)
|
88
|
+
end
|
89
|
+
|
90
|
+
def fail_handshake
|
91
|
+
error = @handshake.errors.first
|
92
|
+
|
93
|
+
response = Rack::Response.new(400)
|
94
|
+
response.render(@socket)
|
95
|
+
|
96
|
+
fail HandshakeError, "(ws.#{@key}) error during handshake: #{error}"
|
97
|
+
end
|
98
|
+
|
99
|
+
def setup
|
100
|
+
logger.info "(ws.#{@key}) client established connection"
|
101
|
+
handle_ws_join
|
102
|
+
|
103
|
+
@parser = WebSocket::Parser.new
|
104
|
+
|
105
|
+
@parser.on_message do |message|
|
106
|
+
handle_ws_message(message)
|
107
|
+
end
|
108
|
+
|
109
|
+
@parser.on_error do |error|
|
110
|
+
logger.error "(ws.#{@key}) encountered error #{error}"
|
111
|
+
handle_ws_error(error)
|
112
|
+
end
|
113
|
+
|
114
|
+
@parser.on_close do |status, message|
|
115
|
+
logger.info "(ws.#{@key}) client closed connection"
|
116
|
+
handle_ws_close(status, message)
|
117
|
+
end
|
118
|
+
|
119
|
+
@parser.on_ping do |payload|
|
120
|
+
handle_ws_ping(payload)
|
121
|
+
end
|
122
|
+
|
123
|
+
@reader = Concurrent::Future.execute {
|
124
|
+
begin
|
125
|
+
loop do
|
126
|
+
break if shutdown?
|
127
|
+
@parser << @socket.read_nonblock(16_384)
|
128
|
+
end
|
129
|
+
rescue ::IO::WaitReadable
|
130
|
+
IO.select([@socket])
|
131
|
+
retry
|
132
|
+
rescue EOFError
|
133
|
+
@parent.delegate.unregister(@key)
|
134
|
+
@parent.shutdown
|
135
|
+
end
|
136
|
+
}
|
137
|
+
end
|
138
|
+
|
139
|
+
def handle_ws_message(message)
|
140
|
+
parsed = JSON.parse(message)
|
141
|
+
logger.debug "(ws.#{@key}) received message: #{JSON.pretty_generate(parsed)}\n"
|
142
|
+
push(MessageHandler.handle(parsed, @req.env['rack.session']))
|
143
|
+
rescue StandardError => e
|
144
|
+
logger.error "(#{@key}): WebSocket encountered an error:"
|
145
|
+
logger.error e.message
|
146
|
+
|
147
|
+
e.backtrace.each do |line|
|
148
|
+
logger.error line
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def handle_ws_error(_error)
|
153
|
+
shutdown
|
154
|
+
end
|
155
|
+
|
156
|
+
def handle_ws_join
|
157
|
+
self.class.handle_event(:join, @req)
|
158
|
+
end
|
159
|
+
|
160
|
+
def handle_ws_close(_status, _message)
|
161
|
+
@socket << WebSocket::Message.close.to_data
|
162
|
+
shutdown
|
163
|
+
end
|
164
|
+
|
165
|
+
def handle_ws_ping(payload)
|
166
|
+
@socket << WebSocket::Message.pong(payload).to_data
|
167
|
+
end
|
168
|
+
|
169
|
+
def self.handle_event(event, req)
|
170
|
+
if Pakyow.app
|
171
|
+
app = Pakyow.app.dup
|
172
|
+
app.context = AppContext.new(req)
|
173
|
+
|
174
|
+
ui = Pakyow.app.instance_variable_get(:@ui)
|
175
|
+
app.context.ui = ui.dup if ui
|
176
|
+
end
|
177
|
+
|
178
|
+
event_handlers(event).each do |block|
|
179
|
+
app.instance_exec(&block)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def self.event_handlers(event = nil)
|
184
|
+
@event_handlers.fetch(event, [])
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
metadata
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pakyow-realtime
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.10.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Bryan Powell
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-10-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: pakyow-support
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.10.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.10.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: pakyow-core
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.10.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.10.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: websocket_parser
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: redis
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.2'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.2'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: concurrent-ruby
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: WebSockets and realtime channels for Pakyow
|
84
|
+
email: bryan@metabahn.com
|
85
|
+
executables: []
|
86
|
+
extensions: []
|
87
|
+
extra_rdoc_files: []
|
88
|
+
files:
|
89
|
+
- pakyow-realtime/CHANGELOG.md
|
90
|
+
- pakyow-realtime/LICENSE
|
91
|
+
- pakyow-realtime/README.md
|
92
|
+
- pakyow-realtime/lib/pakyow-realtime.rb
|
93
|
+
- pakyow-realtime/lib/pakyow-realtime/config.rb
|
94
|
+
- pakyow-realtime/lib/pakyow-realtime/connection.rb
|
95
|
+
- pakyow-realtime/lib/pakyow-realtime/context.rb
|
96
|
+
- pakyow-realtime/lib/pakyow-realtime/delegate.rb
|
97
|
+
- pakyow-realtime/lib/pakyow-realtime/exceptions.rb
|
98
|
+
- pakyow-realtime/lib/pakyow-realtime/ext/request.rb
|
99
|
+
- pakyow-realtime/lib/pakyow-realtime/helpers.rb
|
100
|
+
- pakyow-realtime/lib/pakyow-realtime/hooks.rb
|
101
|
+
- pakyow-realtime/lib/pakyow-realtime/message_handler.rb
|
102
|
+
- pakyow-realtime/lib/pakyow-realtime/message_handlers/call_route.rb
|
103
|
+
- pakyow-realtime/lib/pakyow-realtime/message_handlers/ping.rb
|
104
|
+
- pakyow-realtime/lib/pakyow-realtime/redis_subscription.rb
|
105
|
+
- pakyow-realtime/lib/pakyow-realtime/registries/redis_registry.rb
|
106
|
+
- pakyow-realtime/lib/pakyow-realtime/registries/simple_registry.rb
|
107
|
+
- pakyow-realtime/lib/pakyow-realtime/websocket.rb
|
108
|
+
homepage: http://pakyow.org
|
109
|
+
licenses:
|
110
|
+
- MIT
|
111
|
+
metadata: {}
|
112
|
+
post_install_message:
|
113
|
+
rdoc_options: []
|
114
|
+
require_paths:
|
115
|
+
- pakyow-realtime/lib
|
116
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
117
|
+
requirements:
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: 2.0.0
|
121
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
requirements: []
|
127
|
+
rubyforge_project:
|
128
|
+
rubygems_version: 2.4.5
|
129
|
+
signing_key:
|
130
|
+
specification_version: 4
|
131
|
+
summary: Pakyow Realtime
|
132
|
+
test_files: []
|