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.
@@ -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: []