pakyow-realtime 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,3 @@
1
+ # 0.10.0 (to be released)
2
+
3
+ * Initial release
@@ -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,6 @@
1
+ module Pakyow
2
+ module Realtime
3
+ class HandshakeError < Error; end
4
+ class MissingMessageHandler < Error; end
5
+ end
6
+ end
@@ -0,0 +1,10 @@
1
+ module Pakyow
2
+ class Request
3
+ # Returns true if the request occurred over a WebSocket.
4
+ #
5
+ # @api public
6
+ def socket?
7
+ env['pakyow.socket'] == true
8
+ end
9
+ end
10
+ 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,8 @@
1
+ # Handles pings to keep the WebSocket alive.
2
+ #
3
+ Pakyow::Realtime.handler :ping do |_message, _session, response|
4
+ response[:status] = 200
5
+ response[:headers] = {}
6
+ response[:body] = 'pong'
7
+ response
8
+ 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: []