shot 0.0.2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE.md ADDED
File without changes
data/README.md ADDED
@@ -0,0 +1,33 @@
1
+ Introduction
2
+ ------------
3
+
4
+ The Shot Core is responsible for implementing the base `Application` class, which processes incoming web socket
5
+ connections, turning them into instances of `ApplicationInstance`. The `Application` class also is capable of
6
+ being extended through [**Loaders**](https://github.com/shot-framework/shot-core/wiki/Loaders) and
7
+ [**Routers**](https://github.com/shot-framework/shot-core/wiki/Routers).
8
+
9
+ # Installation
10
+
11
+ ```bash
12
+ gem install shot
13
+ ```
14
+
15
+ # Usage Example
16
+
17
+ ```ruby
18
+ require 'shot'
19
+
20
+ app = Application.new
21
+
22
+ app.run do
23
+ app.on 'load' do |instance|
24
+ instance.emit 'loaded'
25
+ end
26
+ end
27
+
28
+ app.start
29
+ ```
30
+
31
+ # License
32
+
33
+ Licensed under the MIT License. For full licensing information, please see LICENSE.md.
@@ -0,0 +1,100 @@
1
+ # Application
2
+ #
3
+ # Main application instance of a Shot application. Inherits from EventdServer and implements various
4
+ # "routers", which take care of handling special Shot emissions, such as controller calling, etc.
5
+ #
6
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
7
+ # Licensed under the MIT License. For full licensing information, please
8
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
9
+
10
+ require 'eventd'
11
+
12
+ require_relative './application_instance'
13
+
14
+ class Application < EventdServer
15
+ # Array of routers
16
+ attr_accessor :routers
17
+
18
+ # Array of loaders
19
+ attr_accessor :loaders
20
+
21
+ # Active connected clients
22
+ attr_accessor :clients
23
+
24
+ # Temporary data for storage of state-based information
25
+ attr_accessor :data
26
+
27
+ # Initialize the Application and EventdServer. Specifies a default configuration which
28
+ # allows the EventdServer to bind on http://127.0.0.1:8080
29
+
30
+ def initialize(options = { :host => '127.0.0.1', :port => 8080 })
31
+ super(options)
32
+ @routers = []
33
+ @loaders = []
34
+ @clients = []
35
+ @data = {}
36
+ setup_handlers
37
+ end
38
+
39
+ # Add a router to the list of custom routers the application supports.
40
+ #
41
+ # Note:: Existing clients will not be handled by the new router.
42
+ #
43
+ # ==Attributes
44
+ #
45
+ # * +router+ - A class which inherits from Router
46
+
47
+ def add_router(router)
48
+ @routers.push router unless @routers.include? router
49
+ end
50
+
51
+ # Add a loader to the list of custom loaders the application supports.
52
+ #
53
+ # == Attributes
54
+ #
55
+ # * +loader+ - A class which inherits from Loader
56
+
57
+ def add_loader(loader)
58
+ unless @loaders.include? loader
59
+ @loaders.push loader
60
+
61
+ @clients.each do |client|
62
+ client.add_loader loader
63
+ end
64
+ end
65
+ end
66
+
67
+ # Gets a value from the state-based store
68
+
69
+ def get(key)
70
+ @data[key]
71
+ end
72
+
73
+ # Sets a value to the state-based store
74
+
75
+ def set(key, value)
76
+ @data[key] = value
77
+ end
78
+
79
+ private
80
+
81
+ def setup_handlers
82
+ self.on 'connection' do |client|
83
+ client.on 'connect' do
84
+ application_instance = ApplicationInstance.from_eventd_client client, self
85
+
86
+ @clients.push application_instance
87
+
88
+ @loaders.each do |loader|
89
+ application_instance.add_loader loader
90
+ end
91
+
92
+ @routers.each do |router|
93
+ router.new(application_instance)
94
+ end
95
+
96
+ self.emit 'load', application_instance
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,69 @@
1
+ # ApplicationInstance
2
+ #
3
+ # Inherits from EventdClient and represents a single client in a Shot Application.
4
+ #
5
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
6
+ # Licensed under the MIT License. For full licensing information, please
7
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
8
+
9
+ require 'eventd'
10
+
11
+ class ApplicationInstance < EventdClient
12
+ # Array of loaders
13
+ attr_accessor :loaders
14
+
15
+ # Main Application instance
16
+ attr_accessor :app
17
+
18
+ # App Configuration
19
+ attr_accessor :config
20
+
21
+ # Initialize a new ApplicationInstance from an existing EventdClient.
22
+ #
23
+ # Note:: This is the preferred way to initialize an ApplicationInstance.
24
+ #
25
+ # == Attributes
26
+ #
27
+ # * +client+ - EventdClient to copy properties from.
28
+
29
+ def self.from_eventd_client(client, app)
30
+ instance = ApplicationInstance.new
31
+ instance.app = app
32
+ instance.socket = client.socket
33
+ instance.setup_socket
34
+ instance
35
+ end
36
+
37
+ # Initialize the superclass.
38
+ #
39
+ # Note::
40
+ # Please use the ApplicationInstance#from_eventd_client method instead, since it is not usually
41
+ # a good idea to instantiate an EventdClient without a socket.
42
+
43
+ def initialize(socket = nil)
44
+ super(socket)
45
+ @config = {}
46
+ @loaders = {}
47
+ end
48
+
49
+ # Add a loader to the list of custom loaders the application supports.
50
+ #
51
+ # == Attributes
52
+ #
53
+ # * +loader+ - A class which inherits from Loader
54
+
55
+ def add_loader(loader)
56
+ loader_instance = loader.new self
57
+ @loaders[loader_instance.type] = loader_instance
58
+ end
59
+
60
+ # Calls out to any available loader and loads the specified file
61
+
62
+ def get(type, file)
63
+ if @loaders[type]
64
+ @loaders[type].get file
65
+ else
66
+ raise LoaderNotFoundException.new "Could not load type #{type}"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,49 @@
1
+ # ConfigurationLoader
2
+ #
3
+ # Loads all configuration files from the 'application/config' directory
4
+ # if it exists. Otherwise, doesn't do anything.
5
+ #
6
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
7
+ # Licensed under the MIT License. For full licensing information, please
8
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
9
+
10
+ require 'yaml'
11
+
12
+ require_relative './loader'
13
+
14
+ class ConfigurationLoader < Loader
15
+ def initialize(client)
16
+ super(client)
17
+ @type = 'config'
18
+ get_all
19
+ end
20
+
21
+ def get(config_file)
22
+ if config_folder_exists and config_file_exists config_file then
23
+ @client.config[config_file] = YAML.load File.read "./application/config/#{config_file}.yml"
24
+ end
25
+ end
26
+
27
+ def get_all
28
+ if config_folder_exists
29
+ config_folder = Dir.new './application/config/'
30
+
31
+ config_folder.each do |file|
32
+ if file != '.' and file != '..'
33
+ file['.yml'] = ''
34
+ get file
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def config_folder_exists
43
+ Dir.exists? './application/config'
44
+ end
45
+
46
+ def config_file_exists(file)
47
+ File.exists? "./application/config/#{file}.yml"
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # Loader
2
+ #
3
+ # Interface which defines how a Loader should work, and appropriately raises exceptions for unimplemented
4
+ # requirements of a custom Loader.
5
+ #
6
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
7
+ # Licensed under the MIT License. For full licensing information, please
8
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
9
+
10
+
11
+ require_relative './loader_exception'
12
+
13
+ class Loader
14
+ # Loader type
15
+ attr_accessor :type
16
+
17
+ # Associated EventdClient
18
+ attr_accessor :client
19
+
20
+ def initialize(client)
21
+ @client = client
22
+ end
23
+
24
+ def get(name)
25
+ raise LoaderException.new 'Custom Loader #get method has not been overridden.'
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ # LoaderException
2
+ #
3
+ # Custom exception raised when an issue is detected with a custom Loader
4
+ #
5
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
6
+ # Licensed under the MIT License. For full licensing information, please
7
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
8
+
9
+
10
+ class LoaderException < Exception
11
+ def initialize(message)
12
+ super(message)
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # LoaderNotFoundException
2
+ #
3
+ # Raised if a loader is not available for a specified type
4
+ #
5
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
6
+ # Licensed under the MIT License. For full licensing information, please
7
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
8
+
9
+
10
+ class LoaderNotFoundException < Exception
11
+ def initialize(message)
12
+ super(message)
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ # Router
2
+ #
3
+ # Defines the basic structure of a Router. Routers allow for custom behavior for incoming EventdClients when
4
+ # they connect to the Shot Application. Routers should, at the very least, implement the Router#initialize
5
+ # method, which will be passed an instance of the client the router is handling.
6
+ #
7
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
8
+ # Licensed under the MIT License. For full licensing information, please
9
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
10
+
11
+
12
+ class Router
13
+
14
+ # Associated EventdClient
15
+ attr_accessor :client
16
+
17
+ # Initialize the Router, and save the passed client
18
+ #
19
+ # == Attributes
20
+ #
21
+ # * +client+ - Instance of EventdClient, which the router should handle
22
+
23
+ def initialize(client)
24
+ @client = client
25
+ end
26
+ end
data/lib/shot/shot.rb ADDED
@@ -0,0 +1,20 @@
1
+ # What is this item?
2
+ #
3
+ # What does this item do?
4
+ #
5
+ # Shot Framework - Copyright (c) Jesse Aaron Dunlap <me@jessedunlap.me>
6
+ # Licensed under the MIT License. For full licensing information, please
7
+ # see LICENSE.md. http://github.com/JesseDunlap/shot/
8
+
9
+
10
+ class Shot
11
+ # Configure paths for easier load and require access to the lib and application folders
12
+
13
+ def self.setup
14
+ $:.unshift(File.expand_path('lib/', __FILE__.gsub('app.rb', '')))
15
+ $:.unshift(File.expand_path('application/', __FILE__.gsub('app.rb', '')))
16
+
17
+ $LOAD_PATH.unshift(File.expand_path('lib/', __FILE__.gsub('app.rb', '')))
18
+ $LOAD_PATH.unshift(File.expand_path('application/', __FILE__.gsub('app.rb', '')))
19
+ end
20
+ end
data/lib/shot.rb CHANGED
@@ -1,2 +1,7 @@
1
- require_relative '../lib/shot/shot_server'
2
- require_relative '../lib/shot/shot_client'
1
+ require_relative './shot/shot'
2
+ require_relative './shot/application'
3
+ require_relative './shot/router'
4
+ require_relative './shot/loader'
5
+ require_relative './shot/loader_exception'
6
+ require_relative './shot/loader_not_found_exception'
7
+ require_relative './shot/configuration_loader'
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 1.0.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
8
- - Jesse Dunlap
8
+ - Jesse A. Dunlap
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-05-18 00:00:00.000000000 Z
12
+ date: 2013-06-30 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: eventd
@@ -27,18 +27,43 @@ dependencies:
27
27
  - - ! '>='
28
28
  - !ruby/object:Gem::Version
29
29
  version: '0'
30
- description: Shot provides an event based WebSocket client and server for Ruby.
30
+ - !ruby/object:Gem::Dependency
31
+ name: flexmock
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ description: Shot Core provides a fundamental starting point for all Shot applications
47
+ by implementing an EventdServer, and a series of extensible systems (Routers & Loaders).
31
48
  email: me@jessedunlap.me
32
49
  executables: []
33
50
  extensions: []
34
51
  extra_rdoc_files: []
35
52
  files:
53
+ - lib/shot/application.rb
54
+ - lib/shot/application_instance.rb
55
+ - lib/shot/configuration_loader.rb
56
+ - lib/shot/loader.rb
57
+ - lib/shot/loader_exception.rb
58
+ - lib/shot/loader_not_found_exception.rb
59
+ - lib/shot/router.rb
60
+ - lib/shot/shot.rb
36
61
  - lib/shot.rb
37
- - lib/shot/shot_server.rb
38
- - lib/shot/shot_client.rb
39
- - lib/shot/web_socket.rb
40
- homepage: ''
41
- licenses: []
62
+ - LICENSE.md
63
+ - README.md
64
+ homepage: http://github.com/shot/
65
+ licenses:
66
+ - MIT
42
67
  post_install_message:
43
68
  rdoc_options: []
44
69
  require_paths:
@@ -60,5 +85,5 @@ rubyforge_project:
60
85
  rubygems_version: 1.8.24
61
86
  signing_key:
62
87
  specification_version: 3
63
- summary: An event based WebSocket layer.
88
+ summary: The core of Shot, a real time applciation framework written in Ruby.
64
89
  test_files: []
@@ -1,44 +0,0 @@
1
- require 'json'
2
- require 'eventd'
3
-
4
- ##
5
- # An enhanced event-based client. Each connection to a MagicSocketServer initializes
6
- # a new instance of MagicSocketClient for easier interaction.
7
- class MagicSocketClient < Eventd
8
- ##
9
- # MagicSocketClient constructor
10
- def initialize(socket)
11
- super()
12
- @socket = socket
13
-
14
- @socket.onmessage do |message|
15
- begin
16
- json = JSON.parse message
17
-
18
- event = json['event']
19
- parameters = json['parameters']
20
-
21
- # Passing false as third parameter to prevent this being sent
22
- # back over the socket to the client
23
- self.emit event, parameters, false
24
- self.emit 'any', { :event => event, :parameters => parameters }, false
25
- rescue Exception => e
26
- $stderr.puts e
27
- $stderr.puts e.backtrace
28
- end
29
- end
30
-
31
- @socket.onclose do |e|
32
- self.emit 'disconnect', e
33
- end
34
- end
35
-
36
- def emit(event_name, parameters, allow_socket = true)
37
- if allow_socket
38
- json = { :event => event_name, :parameters => parameters }
39
- @socket.send json.to_json
40
- end
41
-
42
- super(event_name, parameters)
43
- end
44
- end
@@ -1,59 +0,0 @@
1
- require 'json'
2
- require 'eventd'
3
- require 'em-websocket'
4
- require_relative 'shot_client'
5
-
6
- ##
7
- # Implements an event-based WebSocket server that accepts client connections
8
- # utilizing the WebSocket protocol. Uses the em-websocket library to create
9
- # a server, accept connections, and handshake with them.
10
- #
11
- # Author:: Jesse A. Dunlap <me@jessedunlap.me>
12
- # License:: MIT
13
-
14
- class ShotServer < Eventd
15
- ##
16
- # MagicSocketServer constructor
17
- def initialize(host = '127.0.0.1', port = 8080)
18
- super()
19
-
20
- @configuration = {
21
- :host => host,
22
- :port => port
23
- }
24
-
25
- @clients = []
26
- end
27
-
28
- ##
29
- # Merges a new configuration object with the existing default configuration.
30
- # Configuration is passed into the EM::WebSocket.run method. This method can
31
- # be used to set up SSL and other em-websocket configuration.
32
- def configure(configuration)
33
- @configuration = @configuration.merge configuration
34
- end
35
-
36
- ##
37
- # Calls the specified callback when the server is running, so that you can then
38
- # handle connections properly.
39
- def run(&callback)
40
- @run = callback
41
- end
42
-
43
- ##
44
- # Binds the websocket connection to the configured host and port, and begins
45
- # handling incoming connections. NOTE: It is important to call this method
46
- # **after** you implement the run method. No code will execute past the point
47
- # at which you call listen.
48
- def listen
49
- EM.run do
50
- if @run then @run.call end
51
-
52
- EM::WebSocket.run(@configuration) do |ws|
53
- ws.onopen do |handshake|
54
- self.emit 'connection', MagicSocketClient.new(ws)
55
- end
56
- end
57
- end
58
- end
59
- end
@@ -1,588 +0,0 @@
1
- # Copyright: Hiroshi Ichikawa <http://gimite.net/en/>
2
- # Lincense: New BSD Lincense
3
- # Reference: http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-75
4
- # Reference: http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-76
5
- # Reference: http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-07
6
- # Reference: http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-10
7
-
8
- require "base64"
9
- require "socket"
10
- require "uri"
11
- require "digest/md5"
12
- require "digest/sha1"
13
- require "openssl"
14
- require "stringio"
15
-
16
-
17
- class WebSocket
18
-
19
- class << self
20
-
21
- attr_accessor(:debug)
22
-
23
- end
24
-
25
- class Error < RuntimeError
26
-
27
- end
28
-
29
- WEB_SOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
30
- OPCODE_CONTINUATION = 0x00
31
- OPCODE_TEXT = 0x01
32
- OPCODE_BINARY = 0x02
33
- OPCODE_CLOSE = 0x08
34
- OPCODE_PING = 0x09
35
- OPCODE_PONG = 0x0a
36
-
37
- def initialize(arg, params = {})
38
- if params[:server] # server
39
-
40
- @server = params[:server]
41
- @socket = arg
42
- line = gets()
43
- if !line
44
- raise(WebSocket::Error, "Client disconnected without sending anything.")
45
- end
46
- line = line.chomp()
47
- if !(line =~ /\AGET (\S+) HTTP\/1.1\z/n)
48
- raise(WebSocket::Error, "Invalid request: #{line}")
49
- end
50
- @path = $1
51
- read_header()
52
- if @header["sec-websocket-version"]
53
- @web_socket_version = @header["sec-websocket-version"]
54
- @key3 = nil
55
- elsif @header["sec-websocket-key1"] && @header["sec-websocket-key2"]
56
- @web_socket_version = "hixie-76"
57
- @key3 = read(8)
58
- else
59
- @web_socket_version = "hixie-75"
60
- @key3 = nil
61
- end
62
- if !@server.accepted_origin?(self.origin)
63
- raise(WebSocket::Error,
64
- ("Unaccepted origin: %s (server.accepted_domains = %p)\n\n" +
65
- "To accept this origin, write e.g. \n" +
66
- " WebSocketServer.new(..., :accepted_domains => [%p]), or\n" +
67
- " WebSocketServer.new(..., :accepted_domains => [\"*\"])\n") %
68
- [self.origin, @server.accepted_domains, @server.origin_to_domain(self.origin)])
69
- end
70
- @handshaked = false
71
-
72
- else # client
73
-
74
- @web_socket_version = "hixie-76"
75
- uri = arg.is_a?(String) ? URI.parse(arg) : arg
76
-
77
- if uri.scheme == "ws"
78
- default_port = 80
79
- elsif uri.scheme = "wss"
80
- default_port = 443
81
- else
82
- raise(WebSocket::Error, "unsupported scheme: #{uri.scheme}")
83
- end
84
-
85
- @path = (uri.path.empty? ? "/" : uri.path) + (uri.query ? "?" + uri.query : "")
86
- host = uri.host + ((!uri.port || uri.port == default_port) ? "" : ":#{uri.port}")
87
- origin = params[:origin] || "http://#{uri.host}"
88
- key1 = generate_key()
89
- key2 = generate_key()
90
- key3 = generate_key3()
91
-
92
- socket = TCPSocket.new(uri.host, uri.port || default_port)
93
-
94
- if uri.scheme == "ws"
95
- @socket = socket
96
- else
97
- @socket = ssl_handshake(socket)
98
- end
99
-
100
- write(
101
- "GET #{@path} HTTP/1.1\r\n" +
102
- "Upgrade: WebSocket\r\n" +
103
- "Connection: Upgrade\r\n" +
104
- "Host: #{host}\r\n" +
105
- "Origin: #{origin}\r\n" +
106
- "Sec-WebSocket-Key1: #{key1}\r\n" +
107
- "Sec-WebSocket-Key2: #{key2}\r\n" +
108
- "\r\n" +
109
- "#{key3}")
110
- flush()
111
-
112
- line = gets().chomp()
113
- raise(WebSocket::Error, "bad response: #{line}") if !(line =~ /\AHTTP\/1.1 101 /n)
114
- read_header()
115
- if (@header["sec-websocket-origin"] || "").downcase() != origin.downcase()
116
- raise(WebSocket::Error,
117
- "origin doesn't match: '#{@header["sec-websocket-origin"]}' != '#{origin}'")
118
- end
119
- reply_digest = read(16)
120
- expected_digest = hixie_76_security_digest(key1, key2, key3)
121
- if reply_digest != expected_digest
122
- raise(WebSocket::Error,
123
- "security digest doesn't match: %p != %p" % [reply_digest, expected_digest])
124
- end
125
- @handshaked = true
126
-
127
- end
128
- @received = []
129
- @buffer = ""
130
- @closing_started = false
131
- end
132
-
133
- attr_reader(:server, :header, :path)
134
-
135
- def handshake(status = nil, header = {})
136
- if @handshaked
137
- raise(WebSocket::Error, "handshake has already been done")
138
- end
139
- status ||= "101 Switching Protocols"
140
- def_header = {}
141
- case @web_socket_version
142
- when "hixie-75"
143
- def_header["WebSocket-Origin"] = self.origin
144
- def_header["WebSocket-Location"] = self.location
145
- extra_bytes = ""
146
- when "hixie-76"
147
- def_header["Sec-WebSocket-Origin"] = self.origin
148
- def_header["Sec-WebSocket-Location"] = self.location
149
- extra_bytes = hixie_76_security_digest(
150
- @header["Sec-WebSocket-Key1"], @header["Sec-WebSocket-Key2"], @key3)
151
- else
152
- def_header["Sec-WebSocket-Accept"] = security_digest(@header["sec-websocket-key"])
153
- extra_bytes = ""
154
- end
155
- header = def_header.merge(header)
156
- header_str = header.map(){ |k, v| "#{k}: #{v}\r\n" }.join("")
157
- # Note that Upgrade and Connection must appear in this order.
158
- write(
159
- "HTTP/1.1 #{status}\r\n" +
160
- "Upgrade: websocket\r\n" +
161
- "Connection: Upgrade\r\n" +
162
- "#{header_str}\r\n#{extra_bytes}")
163
- flush()
164
- @handshaked = true
165
- end
166
-
167
- def send(data)
168
- if !@handshaked
169
- raise(WebSocket::Error, "call WebSocket\#handshake first")
170
- end
171
- case @web_socket_version
172
- when "hixie-75", "hixie-76"
173
- data = force_encoding(data.dup(), "ASCII-8BIT")
174
- write("\x00#{data}\xff")
175
- flush()
176
- else
177
- send_frame(OPCODE_TEXT, data, !@server)
178
- end
179
- end
180
-
181
- def receive()
182
- if !@handshaked
183
- raise(WebSocket::Error, "call WebSocket\#handshake first")
184
- end
185
- case @web_socket_version
186
-
187
- when "hixie-75", "hixie-76"
188
- packet = gets("\xff")
189
- return nil if !packet
190
- if packet =~ /\A\x00(.*)\xff\z/nm
191
- return force_encoding($1, "UTF-8")
192
- elsif packet == "\xff" && read(1) == "\x00" # closing
193
- close(1005, "", :peer)
194
- return nil
195
- else
196
- raise(WebSocket::Error, "input must be either '\\x00...\\xff' or '\\xff\\x00'")
197
- end
198
-
199
- else
200
- begin
201
- bytes = read(2).unpack("C*")
202
- fin = (bytes[0] & 0x80) != 0
203
- opcode = bytes[0] & 0x0f
204
- mask = (bytes[1] & 0x80) != 0
205
- plength = bytes[1] & 0x7f
206
- if plength == 126
207
- bytes = read(2)
208
- plength = bytes.unpack("n")[0]
209
- elsif plength == 127
210
- bytes = read(8)
211
- (high, low) = bytes.unpack("NN")
212
- plength = high * (2 ** 32) + low
213
- end
214
- if @server && !mask
215
- # Masking is required.
216
- @socket.close()
217
- raise(WebSocket::Error, "received unmasked data")
218
- end
219
- mask_key = mask ? read(4).unpack("C*") : nil
220
- payload = read(plength)
221
- payload = apply_mask(payload, mask_key) if mask
222
- case opcode
223
- when OPCODE_TEXT
224
- return force_encoding(payload, "UTF-8")
225
- when OPCODE_BINARY
226
- raise(WebSocket::Error, "received binary data, which is not supported")
227
- when OPCODE_CLOSE
228
- close(1005, "", :peer)
229
- return nil
230
- when OPCODE_PING
231
- raise(WebSocket::Error, "received ping, which is not supported")
232
- when OPCODE_PONG
233
- else
234
- raise(WebSocket::Error, "received unknown opcode: %d" % opcode)
235
- end
236
- rescue EOFError
237
- return nil
238
- end
239
-
240
- end
241
- end
242
-
243
- def tcp_socket
244
- return @socket
245
- end
246
-
247
- def host
248
- return @header["host"]
249
- end
250
-
251
- def origin
252
- case @web_socket_version
253
- when "7", "8"
254
- name = "sec-websocket-origin"
255
- else
256
- name = "origin"
257
- end
258
- if @header[name]
259
- return @header[name]
260
- else
261
- raise(WebSocket::Error, "%s header is missing" % name)
262
- end
263
- end
264
-
265
- def location
266
- return "ws://#{self.host}#{@path}"
267
- end
268
-
269
- # Does closing handshake.
270
- def close(code = 1005, reason = "", origin = :self)
271
- if !@closing_started
272
- case @web_socket_version
273
- when "hixie-75", "hixie-76"
274
- write("\xff\x00")
275
- else
276
- if code == 1005
277
- payload = ""
278
- else
279
- payload = [code].pack("n") + force_encoding(reason.dup(), "ASCII-8BIT")
280
- end
281
- send_frame(OPCODE_CLOSE, payload, false)
282
- end
283
- end
284
- @socket.close() if origin == :peer
285
- @closing_started = true
286
- end
287
-
288
- def close_socket()
289
- @socket.close()
290
- end
291
-
292
- private
293
-
294
- NOISE_CHARS = ("\x21".."\x2f").to_a() + ("\x3a".."\x7e").to_a()
295
-
296
- def read_header()
297
- @header = {}
298
- while line = gets()
299
- line = line.chomp()
300
- break if line.empty?
301
- if !(line =~ /\A(\S+): (.*)\z/n)
302
- raise(WebSocket::Error, "invalid request: #{line}")
303
- end
304
- @header[$1] = $2
305
- @header[$1.downcase()] = $2
306
- end
307
- if !@header["upgrade"]
308
- raise(WebSocket::Error, "Upgrade header is missing")
309
- end
310
- if !(@header["upgrade"] =~ /\AWebSocket\z/i)
311
- raise(WebSocket::Error, "invalid Upgrade: " + @header["upgrade"])
312
- end
313
- if !@header["connection"]
314
- raise(WebSocket::Error, "Connection header is missing")
315
- end
316
- if @header["connection"].split(/,/).grep(/\A\s*Upgrade\s*\z/i).empty?
317
- raise(WebSocket::Error, "invalid Connection: " + @header["connection"])
318
- end
319
- end
320
-
321
- def send_frame(opcode, payload, mask)
322
- payload = force_encoding(payload.dup(), "ASCII-8BIT")
323
- # Setting StringIO's encoding to ASCII-8BIT.
324
- buffer = StringIO.new(force_encoding("", "ASCII-8BIT"))
325
- write_byte(buffer, 0x80 | opcode)
326
- masked_byte = mask ? 0x80 : 0x00
327
- if payload.bytesize <= 125
328
- write_byte(buffer, masked_byte | payload.bytesize)
329
- elsif payload.bytesize < 2 ** 16
330
- write_byte(buffer, masked_byte | 126)
331
- buffer.write([payload.bytesize].pack("n"))
332
- else
333
- write_byte(buffer, masked_byte | 127)
334
- buffer.write([payload.bytesize / (2 ** 32), payload.bytesize % (2 ** 32)].pack("NN"))
335
- end
336
- if mask
337
- mask_key = Array.new(4){ rand(256) }
338
- buffer.write(mask_key.pack("C*"))
339
- payload = apply_mask(payload, mask_key)
340
- end
341
- buffer.write(payload)
342
- write(buffer.string)
343
- end
344
-
345
- def gets(rs = $/)
346
- line = @socket.gets(rs)
347
- $stderr.printf("recv> %p\n", line) if WebSocket.debug
348
- return line
349
- end
350
-
351
- def read(num_bytes)
352
- str = @socket.read(num_bytes)
353
- $stderr.printf("recv> %p\n", str) if WebSocket.debug
354
- if str && str.bytesize == num_bytes
355
- return str
356
- else
357
- raise(EOFError)
358
- end
359
- end
360
-
361
- def write(data)
362
- if WebSocket.debug
363
- data.scan(/\G(.*?(\n|\z))/n) do
364
- $stderr.printf("send> %p\n", $&) if !$&.empty?
365
- end
366
- end
367
- @socket.write(data)
368
- end
369
-
370
- def flush()
371
- @socket.flush()
372
- end
373
-
374
- def write_byte(buffer, byte)
375
- buffer.write([byte].pack("C"))
376
- end
377
-
378
- def security_digest(key)
379
- return Base64.encode64(Digest::SHA1.digest(key + WEB_SOCKET_GUID)).gsub(/\n/, "")
380
- end
381
-
382
- def hixie_76_security_digest(key1, key2, key3)
383
- bytes1 = websocket_key_to_bytes(key1)
384
- bytes2 = websocket_key_to_bytes(key2)
385
- return Digest::MD5.digest(bytes1 + bytes2 + key3)
386
- end
387
-
388
- def apply_mask(payload, mask_key)
389
- orig_bytes = payload.unpack("C*")
390
- new_bytes = []
391
- orig_bytes.each_with_index() do |b, i|
392
- new_bytes.push(b ^ mask_key[i % 4])
393
- end
394
- return new_bytes.pack("C*")
395
- end
396
-
397
- def generate_key()
398
- spaces = 1 + rand(12)
399
- max = 0xffffffff / spaces
400
- number = rand(max + 1)
401
- key = (number * spaces).to_s()
402
- (1 + rand(12)).times() do
403
- char = NOISE_CHARS[rand(NOISE_CHARS.size)]
404
- pos = rand(key.size + 1)
405
- key[pos...pos] = char
406
- end
407
- spaces.times() do
408
- pos = 1 + rand(key.size - 1)
409
- key[pos...pos] = " "
410
- end
411
- return key
412
- end
413
-
414
- def generate_key3()
415
- return [rand(0x100000000)].pack("N") + [rand(0x100000000)].pack("N")
416
- end
417
-
418
- def websocket_key_to_bytes(key)
419
- num = key.gsub(/[^\d]/n, "").to_i() / key.scan(/ /).size
420
- return [num].pack("N")
421
- end
422
-
423
- def force_encoding(str, encoding)
424
- if str.respond_to?(:force_encoding)
425
- return str.force_encoding(encoding)
426
- else
427
- return str
428
- end
429
- end
430
-
431
- def ssl_handshake(socket)
432
- ssl_context = OpenSSL::SSL::SSLContext.new()
433
- ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
434
- ssl_socket.sync_close = true
435
- ssl_socket.connect()
436
- return ssl_socket
437
- end
438
-
439
- end
440
-
441
-
442
- class WebSocketServer
443
-
444
- def initialize(params_or_uri, params = nil)
445
- if params
446
- uri = params_or_uri.is_a?(String) ? URI.parse(params_or_uri) : params_or_uri
447
- params[:port] ||= uri.port
448
- params[:accepted_domains] ||= [uri.host]
449
- else
450
- params = params_or_uri
451
- end
452
- @port = params[:port] || 80
453
- @accepted_domains = params[:accepted_domains]
454
- if !@accepted_domains
455
- raise(ArgumentError, "params[:accepted_domains] is required")
456
- end
457
- if params[:host]
458
- @tcp_server = TCPServer.open(params[:host], @port)
459
- else
460
- @tcp_server = TCPServer.open(@port)
461
- end
462
- end
463
-
464
- attr_reader(:tcp_server, :port, :accepted_domains)
465
-
466
- def run(&block)
467
- while true
468
- Thread.start(accept()) do |s|
469
- begin
470
- ws = create_web_socket(s)
471
- yield(ws) if ws
472
- rescue => ex
473
- print_backtrace(ex)
474
- ensure
475
- begin
476
- ws.close_socket() if ws
477
- rescue
478
- end
479
- end
480
- end
481
- end
482
- end
483
-
484
- def accept()
485
- return @tcp_server.accept()
486
- end
487
-
488
- def accepted_origin?(origin)
489
- domain = origin_to_domain(origin)
490
- return @accepted_domains.any?(){ |d| File.fnmatch(d, domain) }
491
- end
492
-
493
- def origin_to_domain(origin)
494
- if origin == "null" || origin == "file://" # local file
495
- return "null"
496
- else
497
- return URI.parse(origin).host
498
- end
499
- end
500
-
501
- def create_web_socket(socket)
502
- ch = socket.getc()
503
- if ch == ?<
504
- # This is Flash socket policy file request, not an actual Web Socket connection.
505
- send_flash_socket_policy_file(socket)
506
- return nil
507
- else
508
- socket.ungetc(ch) if ch
509
- return WebSocket.new(socket, :server => self)
510
- end
511
- end
512
-
513
- private
514
-
515
- def print_backtrace(ex)
516
- $stderr.printf("%s: %s (%p)\n", ex.backtrace[0], ex.message, ex.class)
517
- for s in ex.backtrace[1..-1]
518
- $stderr.printf(" %s\n", s)
519
- end
520
- end
521
-
522
- # Handles Flash socket policy file request sent when web-socket-js is used:
523
- # http://github.com/gimite/web-socket-js/tree/master
524
- def send_flash_socket_policy_file(socket)
525
- socket.puts('<?xml version="1.0"?>')
526
- socket.puts('<!DOCTYPE cross-domain-policy SYSTEM ' +
527
- '"http://www.macromedia.com/xml/dtds/cross-domain-policy.dtd">')
528
- socket.puts('<cross-domain-policy>')
529
- for domain in @accepted_domains
530
- next if domain == "file://"
531
- socket.puts("<allow-access-from domain=\"#{domain}\" to-ports=\"#{@port}\"/>")
532
- end
533
- socket.puts('</cross-domain-policy>')
534
- socket.close()
535
- end
536
-
537
- end
538
-
539
-
540
- if __FILE__ == $0
541
- Thread.abort_on_exception = true
542
-
543
- if ARGV[0] == "server" && ARGV.size == 3
544
-
545
- server = WebSocketServer.new(
546
- :accepted_domains => [ARGV[1]],
547
- :port => ARGV[2].to_i())
548
- puts("Server is running at port %d" % server.port)
549
- server.run() do |ws|
550
- puts("Connection accepted")
551
- puts("Path: #{ws.path}, Origin: #{ws.origin}")
552
- if ws.path == "/"
553
- ws.handshake()
554
- while data = ws.receive()
555
- printf("Received: %p\n", data)
556
- ws.send(data)
557
- printf("Sent: %p\n", data)
558
- end
559
- else
560
- ws.handshake("404 Not Found")
561
- end
562
- puts("Connection closed")
563
- end
564
-
565
- elsif ARGV[0] == "client" && ARGV.size == 2
566
-
567
- client = WebSocket.new(ARGV[1])
568
- puts("Connected")
569
- Thread.new() do
570
- while data = client.receive()
571
- printf("Received: %p\n", data)
572
- end
573
- end
574
- $stdin.each_line() do |line|
575
- data = line.chomp()
576
- client.send(data)
577
- printf("Sent: %p\n", data)
578
- end
579
-
580
- else
581
-
582
- $stderr.puts("Usage:")
583
- $stderr.puts(" ruby web_socket.rb server ACCEPTED_DOMAIN PORT")
584
- $stderr.puts(" ruby web_socket.rb client ws://HOST:PORT/")
585
- exit(1)
586
-
587
- end
588
- end