├── .gitignore ├── README.md ├── TODO ├── examples ├── http.conf.py └── test.conf.py ├── raiden ├── __init__.py ├── gateways │ ├── __init__.py │ ├── httpstream.py │ └── websocket.py ├── patched.py ├── pubsub.py └── vendor │ ├── __init__.py │ └── websocket_hixie.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | serviced.log 3 | serviced.pid 4 | *.egg-info 5 | **/*.pyc 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## phase 1 2 | Functional parity with Realtime minus delegates. Proof of concept. 3 | 4 | * WSGI web frontend that uses Middleware for routing 5 | * ClusterRoster API (not specific to any polling/push mechanism) 6 | * HttpStream "gateway" middleware 7 | * WebSocket "gateway" middleware 8 | * Filtering 9 | * Functional tests pass 10 | * Performance tests exist 11 | 12 | ## phase 2 13 | Some form of delegates for business logic. Ready for deploy. 14 | 15 | * Utils to encourage JWT based auth 16 | * Real business logic out of scope (this is public!) 17 | * Unit tests exist 18 | * Performance optimizations 19 | 20 | ## phase 3 21 | Immediate improvements 22 | 23 | * Sessions / "semi-reliable messaging" 24 | * Node affinity 25 | * Websocket multiplexing and publishing 26 | 27 | ## phase 4 28 | Future improvements 29 | 30 | * Delivery acknowledgement / "reliable messaging" 31 | * HTTP long-polling 32 | * Webhooks? -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - websocket gateway 2 | 3 | - code metrics 4 | - performance testing -------------------------------------------------------------------------------- /examples/http.conf.py: -------------------------------------------------------------------------------- 1 | http_port = 8088 2 | backend_port = 9989 3 | 4 | def service(): 5 | from raiden.gateways.httpstream import HttpStreamGateway 6 | from raiden.gateways.websocket import WebSocketGateway 7 | from raiden.pubsub import MessagingBackend 8 | from gevent_tools.service import Service 9 | import gevent, random, json 10 | 11 | class HttpStreamer(Service): 12 | def __init__(self): 13 | self.backend = MessagingBackend() 14 | self.http_frontend = HttpStreamGateway(self.backend) 15 | self.ws_frontend = WebSocketGateway(self.backend, attach_to=self.http_frontend.wsgi_server) 16 | 17 | self.add_service(self.backend) 18 | self.add_service(self.http_frontend) 19 | self.add_service(self.ws_frontend) 20 | 21 | def do_start(self): 22 | print "Gateway listening on %s..." % self.http_frontend.port 23 | self.backend.cluster.add('127.0.0.1') 24 | self.spawn(self.message_publisher) 25 | 26 | def message_publisher(self): 27 | while True: 28 | self.backend.publish('localhost:%s/test' % self.http_frontend.port, 29 | dict(foo='bar', baz=random.choice(['one', 'two', 'three']))) 30 | print self.backend.router.subscriber_counts 31 | gevent.sleep(2) 32 | 33 | return HttpStreamer() 34 | -------------------------------------------------------------------------------- /examples/test.conf.py: -------------------------------------------------------------------------------- 1 | http_port = 8088 2 | backend_port = 9989 3 | 4 | def service(): 5 | from gevent_tools.service import Service 6 | from raiden.pubsub import MessagingBackend 7 | from raiden.gateways.httpstream import HttpStreamGateway 8 | from raiden.gateways.websocket import WebSocketGateway 9 | 10 | class MyService(Service): 11 | def __init__(self): 12 | self.backend = MessagingBackend() 13 | self.frontend = HttpStreamGateway(self.backend) 14 | 15 | self.add_service(self.backend) 16 | self.add_service(self.frontend) 17 | 18 | self.gateway = WebSocketGateway(attach_to=self.frontend.wsgi_server) 19 | self.add_service(self.gateway) 20 | 21 | def do_start(self): 22 | pass 23 | 24 | return MyService() -------------------------------------------------------------------------------- /raiden/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progrium/raiden/d4bcfc9c8c005d6d62a048821dc17e4d8b902784/raiden/__init__.py -------------------------------------------------------------------------------- /raiden/gateways/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progrium/raiden/d4bcfc9c8c005d6d62a048821dc17e4d8b902784/raiden/gateways/__init__.py -------------------------------------------------------------------------------- /raiden/gateways/httpstream.py: -------------------------------------------------------------------------------- 1 | import json 2 | import errno 3 | import socket 4 | 5 | import gevent.pywsgi 6 | import gevent.queue 7 | import webob 8 | 9 | from gevent_tools.config import Option 10 | from gevent_tools.service import Service 11 | 12 | import raiden.patched 13 | from raiden.pubsub import MessagingBackend 14 | from raiden.pubsub import Subscription 15 | 16 | class HttpStreamGateway(Service): 17 | port = Option('http_port', default=80) 18 | channel_builder = Option('http_channel_builder', 19 | default=lambda req: '%s%s' % (req.host, req.path)) 20 | 21 | def __init__(self, backend): 22 | self.backend = backend 23 | self.wsgi_server = _WSGIServer(self) 24 | 25 | self.add_service(self.wsgi_server) 26 | 27 | # This is to catch errno.ECONNRESET error created 28 | # by WSGIServer when it tries to read after writing 29 | # to a broken pipe, which is already caught and used 30 | # for handling disconnects. 31 | self.catch(IOError, lambda e,g: None) 32 | 33 | def handle(self, env, start_response): 34 | if env['REQUEST_METHOD'] == 'POST': 35 | return self.handle_publish(env, start_response) 36 | elif env['REQUEST_METHOD'] == 'GET': 37 | return self.handle_subscribe(env, start_response) 38 | else: 39 | start_response('405 Method not allowed', []) 40 | return ["Method not allowed\n"] 41 | 42 | def handle_publish(self, env, start_response): 43 | request = webob.Request(env) 44 | if request.content_type.endswith('/json'): 45 | try: 46 | message = json.loads(request.body) 47 | except ValueError: 48 | start_response('400 Invalid JSON', [ 49 | ('Content-Type', 'text/plain')]) 50 | return ["Invalid JSON"] 51 | elif request.content_type.startswith('text/'): 52 | message = {'_': request.body} 53 | else: 54 | message = dict(request.str_POST) 55 | self.backend.publish( 56 | self.channel_builder(request), message) 57 | start_response('200 OK', [ 58 | ('Content-Type', 'text/plain')]) 59 | return ["OK\n"] 60 | 61 | def handle_subscribe(self, env, start_response): 62 | request = webob.Request(env) 63 | filters = request.str_GET.items() 64 | subscription = self.backend.subscribe( 65 | self.channel_builder(request), filters=filters) 66 | yield subscription # send to container to include on disconnect 67 | 68 | start_response('200 OK', [ 69 | ('Content-Type', 'application/json'), 70 | ('Connection', 'keep-alive'), 71 | ('Cache-Control', 'no-cache, must-revalidate'), 72 | ('Expires', 'Tue, 11 Sep 1985 19:00:00 GMT'),]) 73 | for msgs in subscription: 74 | if msgs is None: 75 | yield '\n' 76 | else: 77 | yield '%s\n' % '\n'.join(msgs) 78 | 79 | def handle_disconnect(self, socket, subscription): 80 | subscription.cancel() 81 | 82 | class _WSGIServer(gevent.pywsgi.WSGIServer): 83 | """ 84 | Custom WSGI container that is made to work with HttpStreamGateway, but more 85 | importantly catches disconnects and passes the event as `handle_disconnect` 86 | to the gateway. The only weird thing is that we modify the protocol of WSGI 87 | in that the application's first yield (or first element of returned list, 88 | but we're streaming, so we use yield) will be some kind of object that will 89 | be passed to the disconnect handler to identify the request. 90 | """ 91 | 92 | class handler_class(raiden.patched.WSGIHandler): 93 | def process_result(self): 94 | if hasattr(self.result, 'next'): 95 | request_obj = self.result.next() 96 | try: 97 | super(self.__class__, self).process_result() 98 | except socket.error, ex: 99 | # Broken pipe, connection reset by peer 100 | if ex[0] in (errno.EPIPE, errno.ECONNRESET): 101 | self.close_connection = True 102 | if hasattr(self.server.gateway, 'handle_disconnect'): 103 | self.server.gateway.handle_disconnect( 104 | self.socket, request_obj) 105 | else: 106 | raise 107 | else: 108 | super(self.__class__, self).process_result() 109 | 110 | def __init__(self, gateway): 111 | self.gateway = gateway 112 | super(_WSGIServer, self).__init__( 113 | listener=('127.0.0.1', gateway.port), 114 | application=gateway.handle, 115 | spawn=gateway.spawn, 116 | log=None) -------------------------------------------------------------------------------- /raiden/gateways/websocket.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import gevent.pywsgi 4 | from gevent_tools.config import Option 5 | from gevent_tools.service import Service 6 | from ws4py.server.wsgi.middleware import WebSocketUpgradeMiddleware as HybiUpgrader 7 | 8 | import raiden.patched 9 | from raiden.vendor.websocket_hixie import WebSocketUpgradeMiddleware as HixieUpgrader 10 | from raiden.vendor.websocket_hixie import WebSocketError 11 | 12 | class WebSocketGateway(Service): 13 | port = Option('websocket_port', default=8080) 14 | path = Option('websocket_path', default='/-/websocket') 15 | 16 | def __init__(self, backend, attach_to=None): 17 | self.backend = backend 18 | 19 | if attach_to is None: 20 | self.server = gevent.pywsgi.WSGIServer(('127.0.0.1', self.port), 21 | application=WebSocketMiddleware(self.path, self.handle), 22 | handler_class=raiden.patched.WSGIHandler) 23 | self.add_service(self.server) 24 | else: 25 | self.server = attach_to 26 | self.server.application = WebSocketMiddleware(self.path, 27 | self.handle, self.server.application) 28 | 29 | def handle(self, websocket, environ): 30 | while not websocket.terminated: 31 | ctl_message = websocket.receive() 32 | if ctl_message is not None: 33 | try: 34 | ctl_message = json.loads(ctl_message) 35 | except ValueError: 36 | continue # TODO: log error 37 | if 'cmd' in ctl_message: 38 | cmd = ctl_message.pop('cmd') 39 | self.spawn(getattr(self, 'handle_%s' % cmd), websocket, **ctl_message) 40 | 41 | def handle_subscribe(self, websocket, channel, filters=None): 42 | subscription = self.backend.subscribe(channel, filters) 43 | for msgs in subscription: 44 | if msgs is None: 45 | websocket.send('\n') 46 | else: 47 | websocket.send('%s\n' % '\n'.join(msgs)) 48 | 49 | 50 | class WebSocketMiddleware(object): 51 | def __init__(self, path, handler, fallback_app=None): 52 | self.path = path 53 | self.handler = handler 54 | self.fallback_app = fallback_app or self.default_fallback 55 | self.upgrader = HybiUpgrader(self.handler, HixieUpgrader(self.handler)) 56 | 57 | def __call__(self, environ, start_response): 58 | if environ.get('PATH_INFO') == self.path: 59 | try: 60 | return self.upgrader(environ, start_response) 61 | except WebSocketError, e: 62 | return self.fallback_app(environ, start_response) 63 | else: 64 | return self.fallback_app(environ, start_response) 65 | 66 | def default_fallback(self, environ, start_response): 67 | start_response("404 Not Found", []) 68 | return ["Not Found"] -------------------------------------------------------------------------------- /raiden/patched.py: -------------------------------------------------------------------------------- 1 | from ws4py.server.geventserver import UpgradableWSGIHandler as WSGIHandler -------------------------------------------------------------------------------- /raiden/pubsub.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import time 3 | import json 4 | 5 | import msgpack 6 | import gevent.queue 7 | from gevent_zeromq import zmq 8 | 9 | from gevent_tools.config import Option 10 | from gevent_tools.service import Service 11 | from gevent_tools.service import require_ready 12 | 13 | context = zmq.Context() 14 | 15 | class MessagingException(Exception): pass 16 | 17 | class Subscription(gevent.queue.Queue): 18 | keepalive_secs = Option('keepalive', default=30) 19 | 20 | def __init__(self, router, channel, filters=None): 21 | super(Subscription, self).__init__(maxsize=64) 22 | self.channel = str(channel) 23 | self.router = router 24 | self.filters = filters 25 | router.subscribe(channel, self) 26 | router.spawn(self._keepalive) 27 | 28 | def _keepalive(self): 29 | while self.router: 30 | self.put(None) 31 | gevent.sleep(self.keepalive_secs) 32 | 33 | def cancel(self): 34 | self.router.unsubscribe(self.channel, self) 35 | self.router = None 36 | 37 | def put(self, messages): 38 | # Always allow None since it represents a keepalive 39 | if messages is not None: 40 | # Perform any filtering 41 | if self.filters and len(messages): 42 | def _filter(message): 43 | # Make sure all keys in filter are in message 44 | required_keys = set([k for k,v in self.filters]) 45 | if not required_keys.issubset(message.keys()): 46 | return False 47 | # OR across filters with same key, AND across keys 48 | matches = [] 49 | for key in message: 50 | values = [v for k,v in self.filters if k == key] 51 | if len(values): 52 | matches.append(message[key] in values) 53 | return all(matches) 54 | messages = filter(_filter, messages) 55 | if not len(messages): return 56 | # Serialize to JSON strings 57 | messages = map(json.dumps, messages) 58 | super(Subscription, self).put(messages) 59 | 60 | def __del__(self): 61 | if self.router: 62 | self.cancel() 63 | 64 | 65 | class Observable(object): 66 | # TODO: move to a util module 67 | 68 | def __init__(self): 69 | self._observers = [] 70 | 71 | def attach(self, observer): 72 | if not observer in self._observers: 73 | self._observers.append(observer) 74 | 75 | def detach(self, observer): 76 | try: 77 | self._observers.remove(observer) 78 | except ValueError: 79 | pass 80 | 81 | def notify(self, *args, **kwargs): 82 | for observer in self._observers: 83 | if hasattr(observer, '__call__'): 84 | observer(*args, **kwargs) 85 | else: 86 | observer.update(*args, **kawrgs) 87 | 88 | class ClusterRoster(Observable): 89 | def __init__(self): 90 | super(ClusterRoster, self).__init__() 91 | self._roster = set() 92 | 93 | def add(self, host): 94 | self._roster.add(host) 95 | self.notify(add=host) 96 | 97 | def remove(self, host): 98 | self._roster.discard(host) 99 | self.notify(remove=host) 100 | 101 | def __iter__(self): 102 | return self._roster.__iter__() 103 | 104 | 105 | class MessagingBackend(Service): 106 | port = Option('backend_port') 107 | 108 | def __init__(self): 109 | self.cluster = ClusterRoster() 110 | self.publisher = MessagePublisher(self.cluster, self.port) 111 | self.router = MessageRouter('tcp://127.0.0.1:%s' % self.port) 112 | 113 | self.add_service(self.publisher) 114 | self.add_service(self.router) 115 | 116 | def publish(self, channel, message): 117 | self.publisher.publish(channel, message) 118 | 119 | def subscribe(self, channel, filters=None): 120 | return Subscription(self.router, channel, filters) 121 | 122 | class MessagePublisher(Service): 123 | # TODO: batching socket sends based on publish frequency. 124 | # Although that probably won't provide benefit unless under 125 | # SUPER high load. 126 | 127 | def __init__(self, cluster, port): 128 | self.cluster = cluster 129 | self.port = port 130 | self.socket = context.socket(zmq.PUB) 131 | 132 | def do_start(self): 133 | for host in self.cluster: 134 | self.connect(host) 135 | def connector(add=None, remove=None): 136 | if add: self.connect(add) 137 | self.cluster.attach(connector) 138 | 139 | def connect(self, host): 140 | self.socket.connect('tcp://%s:%s' % (host, self.port)) 141 | 142 | @require_ready 143 | def publish(self, channel, message): 144 | self.socket.send_multipart([str(channel).lower(), msgpack.packb(message)]) 145 | 146 | class MessageRouter(Service): 147 | max_channels = Option('max_channels', default=65536) 148 | max_subscribers = Option('max_subscribers', default=65536) 149 | 150 | def __init__(self, address): 151 | self.address = address 152 | self.socket = context.socket(zmq.SUB) 153 | 154 | self.channels = dict() 155 | self.subscriber_counts = collections.Counter() 156 | 157 | def do_start(self): 158 | self.socket.bind(self.address) 159 | self.spawn(self._listen) 160 | 161 | def subscribe(self, channel, subscriber): 162 | channel = str(channel).lower() 163 | 164 | # Initialize channel if necessary 165 | if not self.channels.get(channel): 166 | if len(self.channels) >= self.max_channels: 167 | raise MessagingException( 168 | "Unable to init channel. Max channels reached: %s" % 169 | self.max_channels) 170 | self.channels[channel] = ChannelDispatcher(self) 171 | 172 | # Create subscription unless max reached 173 | if sum(self.subscriber_counts.values()) >= self.max_subscribers: 174 | raise MessagingException( 175 | "Unable to subscribe. Max subscribers reached: %s" % 176 | self.max_subscribers) 177 | self.socket.setsockopt(zmq.SUBSCRIBE, channel) 178 | self.subscriber_counts[channel] += 1 179 | self.channels[channel].add(subscriber) 180 | 181 | def unsubscribe(self, channel, subscriber): 182 | channel = str(channel).lower() 183 | 184 | self.socket.setsockopt(zmq.UNSUBSCRIBE, channel) 185 | self.subscriber_counts[channel] -= 1 186 | self.channels[channel].remove(subscriber) 187 | 188 | # Clean up counts and ChannelDispatchers with no subscribers 189 | self.subscriber_counts += collections.Counter() 190 | if not self.subscriber_counts[channel]: 191 | del self.channels[channel] 192 | 193 | def _listen(self): 194 | while True: 195 | channel, message = self.socket.recv_multipart() 196 | if self.subscriber_counts[channel]: 197 | self.channels[channel].send(msgpack.unpackb(message)) 198 | 199 | class ChannelDispatcher(object): 200 | def __init__(self, router): 201 | self.router = router 202 | self.purge() 203 | 204 | def purge(self): 205 | self.buffer = [] 206 | self.subscribers = set() 207 | self.draining = False 208 | 209 | def send(self, message): 210 | self.buffer.append(message) 211 | self.drain() 212 | 213 | def add(self, subscriber): 214 | self.subscribers.add(subscriber) 215 | 216 | def remove(self, subscriber): 217 | self.subscribers.remove(subscriber) 218 | if not len(self.subscribers): 219 | self.purge() 220 | 221 | def drain(self): 222 | """ 223 | Unless already draining, this creates a greenlet that will flush the 224 | buffer to subscribers then delay the next flush depending on how many 225 | subscribers there are. This continues until the buffer remains empty. 226 | It will start again with the next call to send(). Since the buffer is 227 | flushed to a subscriber and a subscriber is ultimately an open socket, 228 | this helps reduce the number of socket operations when there are a 229 | large number of open sockets. 230 | """ 231 | if self.draining: 232 | return 233 | def _drain(): 234 | self.draining = True 235 | while self.draining and self.buffer: 236 | start_time = time.time() 237 | batch = self.buffer[:] 238 | if batch: 239 | del self.buffer[:] 240 | for subscriber in self.subscribers: 241 | if hasattr(subscriber, 'put'): 242 | subscriber.put(batch) 243 | else: 244 | subscriber(batch) 245 | delta_time = time.time() - start_time 246 | interval = self._batch_interval() 247 | if delta_time > interval: 248 | gevent.sleep(0.0) # yield 249 | else: 250 | gevent.sleep(interval - delta_time) 251 | self.draining = False 252 | self.router.spawn(_drain) 253 | 254 | def _batch_interval(self): 255 | if len(self.subscribers) <= 10: 256 | return 0.0 257 | elif len(self.subscribers) <= 100: 258 | return 0.25 259 | elif len(self.subscribers) <= 1000: 260 | return 0.5 261 | else: 262 | return 1.0 -------------------------------------------------------------------------------- /raiden/vendor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progrium/raiden/d4bcfc9c8c005d6d62a048821dc17e4d8b902784/raiden/vendor/__init__.py -------------------------------------------------------------------------------- /raiden/vendor/websocket_hixie.py: -------------------------------------------------------------------------------- 1 | import re 2 | import struct 3 | from hashlib import md5 4 | from socket import error 5 | 6 | from gevent.pywsgi import WSGIHandler 7 | from gevent.event import Event 8 | from gevent.coros import Semaphore 9 | 10 | # This module implements the Websocket protocol draft version as of May 23, 2010 11 | # based on the gevent-websocket project by Jeffrey Gelens 12 | 13 | class WebSocketError(error): 14 | pass 15 | 16 | class WebSocket(object): 17 | def __init__(self, sock, environ): 18 | self.rfile = sock.makefile('rb', -1) 19 | self.socket = sock 20 | self.origin = environ.get('HTTP_ORIGIN') 21 | self.protocol = environ.get('HTTP_SEC_WEBSOCKET_PROTOCOL', 'unknown') 22 | self.path = environ.get('PATH_INFO') 23 | self._writelock = Semaphore(1) 24 | 25 | def send(self, message): 26 | if isinstance(message, unicode): 27 | message = message.encode('utf-8') 28 | elif isinstance(message, str): 29 | message = unicode(message).encode('utf-8') 30 | else: 31 | raise Exception("Invalid message encoding") 32 | 33 | with self._writelock: 34 | self.socket.sendall("\x00" + message + "\xFF") 35 | 36 | @property 37 | def terminated(self): 38 | """ 39 | Returns True if both the client and server have been 40 | marked as terminated. 41 | """ 42 | return self.socket is None 43 | 44 | def detach(self): 45 | self.socket = None 46 | self.rfile = None 47 | self.handler = None 48 | 49 | def close(self): 50 | # TODO implement graceful close with 0xFF frame 51 | if self.socket is not None: 52 | try: 53 | self.socket.close() 54 | except Exception: 55 | pass 56 | self.detach() 57 | 58 | 59 | def _message_length(self): 60 | # TODO: buildin security agains lengths greater than 2**31 or 2**32 61 | length = 0 62 | 63 | while True: 64 | byte_str = self.rfile.read(1) 65 | 66 | if not byte_str: 67 | return 0 68 | else: 69 | byte = ord(byte_str) 70 | 71 | if byte != 0x00: 72 | length = length * 128 + (byte & 0x7f) 73 | if (byte & 0x80) != 0x80: 74 | break 75 | 76 | return length 77 | 78 | def _read_until(self): 79 | bytes = [] 80 | 81 | while True: 82 | byte = self.rfile.read(1) 83 | if ord(byte) != 0xff: 84 | bytes.append(byte) 85 | else: 86 | break 87 | 88 | return ''.join(bytes) 89 | 90 | def receive(self): 91 | while not self.terminated: 92 | frame_str = self.rfile.read(1) 93 | if not frame_str: 94 | # Connection lost? 95 | self.close() 96 | break 97 | else: 98 | frame_type = ord(frame_str) 99 | 100 | 101 | if (frame_type & 0x80) == 0x00: # most significant byte is not set 102 | if frame_type == 0x00: 103 | bytes = self._read_until() 104 | return bytes.decode("utf-8", "replace") 105 | else: 106 | self.close() 107 | elif (frame_type & 0x80) == 0x80: # most significant byte is set 108 | # Read binary data (forward-compatibility) 109 | if frame_type != 0xff: 110 | self.close() 111 | break 112 | else: 113 | length = self._message_length() 114 | if length == 0: 115 | self.close() 116 | break 117 | else: 118 | self.rfile.read(length) # discard the bytes 119 | else: 120 | raise IOError("Reveiced an invalid message") 121 | 122 | class WebSocketUpgradeMiddleware(object): 123 | """ Automatically upgrades the connection to websockets. """ 124 | def __init__(self, handler): 125 | self.handler = handler 126 | 127 | def __call__(self, environ, start_response): 128 | if environ.get('upgrade.protocol') != 'websocket': 129 | raise WebSocketError("Not a websocket upgrade") 130 | 131 | self.environ = environ 132 | self.socket = self.environ.get('upgrade.socket') 133 | self.websocket = WebSocket(self.socket, self.environ) 134 | 135 | headers = [ 136 | ("Upgrade", "WebSocket"), 137 | ("Connection", "Upgrade"), 138 | ] 139 | 140 | # Detect the Websocket protocol 141 | if "HTTP_SEC_WEBSOCKET_KEY1" in environ: 142 | version = 76 143 | else: 144 | version = 75 145 | 146 | if version == 75: 147 | headers.extend([ 148 | ("WebSocket-Origin", self.websocket.origin), 149 | ("WebSocket-Protocol", self.websocket.protocol), 150 | ("WebSocket-Location", "ws://%s%s" % (self.environ.get('HTTP_HOST'), self.websocket.path)), 151 | ]) 152 | start_response("101 Web Socket Hixie Handshake", headers) 153 | elif version == 76: 154 | challenge = self._get_challenge() 155 | headers.extend([ 156 | ("Sec-WebSocket-Origin", self.websocket.origin), 157 | ("Sec-WebSocket-Protocol", self.websocket.protocol), 158 | ("Sec-WebSocket-Location", "ws://%s%s" % (self.environ.get('HTTP_HOST'), self.websocket.path)), 159 | ]) 160 | 161 | start_response("101 Web Socket Hixie Handshake", headers) 162 | self.socket.sendall(challenge) 163 | else: 164 | raise WebSocketError("WebSocket version not supported") 165 | 166 | self.environ['websocket.version'] = 'hixie%s' % version 167 | 168 | self.handler(self.websocket, self.environ) 169 | #self.websocket.finished.wait() 170 | 171 | 172 | def _get_key_value(self, key_value): 173 | key_number = int(re.sub("\\D", "", key_value)) 174 | spaces = re.subn(" ", "", key_value)[1] 175 | 176 | if key_number % spaces != 0: 177 | raise WebSocketError("key_number %d is not an intergral multiple of" 178 | " spaces %d" % (key_number, spaces)) 179 | 180 | return key_number / spaces 181 | 182 | def _get_challenge(self): 183 | key1 = self.environ.get('HTTP_SEC_WEBSOCKET_KEY1') 184 | key2 = self.environ.get('HTTP_SEC_WEBSOCKET_KEY2') 185 | 186 | if not key1: 187 | raise WebSocketError("SEC-WEBSOCKET-KEY1 header is missing") 188 | if not key2: 189 | raise WebSocketError("SEC-WEBSOCKET-KEY2 header is missing") 190 | 191 | part1 = self._get_key_value(self.environ['HTTP_SEC_WEBSOCKET_KEY1']) 192 | part2 = self._get_key_value(self.environ['HTTP_SEC_WEBSOCKET_KEY2']) 193 | 194 | # This request should have 8 bytes of data in the body 195 | key3 = self.environ.get('wsgi.input').rfile.read(8) 196 | 197 | return md5(struct.pack("!II", part1, part2) + key3).digest() 198 | 199 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from setuptools import Command 4 | from setuptools import setup, find_packages 5 | 6 | def shell(cmdline): 7 | args = cmdline.split(' ') 8 | os.execlp(args[0], *args) 9 | 10 | class GToolsCommand(Command): 11 | def initialize_options(self): pass 12 | def finalize_options(self): pass 13 | user_options = [] 14 | 15 | class TestCommand(GToolsCommand): 16 | description = "run tests with nose" 17 | 18 | def run(self): 19 | shell("nosetests") 20 | 21 | class CoverageCommand(GToolsCommand): 22 | description = "run test coverage report with nose" 23 | 24 | def run(self): 25 | shell("nosetests --with-coverage --cover-package=raiden") 26 | 27 | class CleanCommand(GToolsCommand): 28 | description = "delete stupid shit left around" 29 | files = "Raiden.egg-info build serviced.log serviced.pid" 30 | 31 | def run(self): 32 | shell("rm -rf %s" % self.files) 33 | 34 | setup( 35 | name='Raiden', 36 | version='0.1.0', 37 | author='Jeff Lindsay', 38 | author_email='jeff.lindsay@twilio.com', 39 | description='', 40 | packages=find_packages(), 41 | install_requires=['gevent_tools', 'gevent_zeromq', 'msgpack-python', 'webob', 'ws4py'], 42 | data_files=[], 43 | cmdclass={ 44 | 'test': TestCommand, 45 | 'coverage': CoverageCommand, 46 | 'clean': CleanCommand,} 47 | ) 48 | --------------------------------------------------------------------------------