|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +""" |
| 3 | +WebSocket demo with a twist. |
| 4 | +
|
| 5 | +The idea of this little demo is to demonstrate |
| 6 | +how it can enable interesting functions when coupled with |
| 7 | +other HTML5 features, such as Canvas. |
| 8 | +
|
| 9 | +In this scenario, we create a canvas that we call a drawing |
| 10 | +board. You can draw circles with various colors. A board |
| 11 | +has a unique URL, every client connecting to that URL |
| 12 | +can see whatever is drawn by other participants. All events |
| 13 | +from one client are sent to other clients through |
| 14 | +websockets. All other clients then draw on their view |
| 15 | +events they receive. |
| 16 | +
|
| 17 | +This ought to work well with any HTML5 capable browser such |
| 18 | +as Chrome, Firefox, Opera. It even works well on Android using |
| 19 | +Chrome. Internet Explorer users... well it may work as well. |
| 20 | +
|
| 21 | +This demo uses: |
| 22 | +
|
| 23 | +* CherryPy 3.2.5+ |
| 24 | +* Mako |
| 25 | +* jcanvas |
| 26 | +* jquery |
| 27 | +* HTML5boilerplate (via http://www.initializr.com/) |
| 28 | +
|
| 29 | +""" |
| 30 | +import json |
| 31 | +import os.path |
| 32 | +import tempfile |
| 33 | +import time |
| 34 | +import uuid |
| 35 | + |
| 36 | +from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool |
| 37 | +from ws4py.websocket import WebSocket |
| 38 | + |
| 39 | +import cherrypy |
| 40 | +from cherrypy.process import plugins |
| 41 | +from mako.lookup import TemplateLookup |
| 42 | +from mako.template import Template |
| 43 | + |
| 44 | +cwd_dir = os.path.normpath(os.path.abspath(os.path.dirname(__file__))) |
| 45 | +bus = cherrypy.engine |
| 46 | +lookup = TemplateLookup(directories=os.path.join(cwd_dir, 'templates'), |
| 47 | + module_directory=os.path.join(cwd_dir, 'templates', '.cache'), |
| 48 | + input_encoding='utf-8', |
| 49 | + output_encoding='utf-8', |
| 50 | + collection_size=20) |
| 51 | + |
| 52 | +class DrawingBoardWebSocketHandler(WebSocket): |
| 53 | + """ |
| 54 | + WebSocket handler that will dispatch drawing events |
| 55 | + from this client to all other registered clients |
| 56 | + on this board. |
| 57 | +
|
| 58 | + An instance of this class is automatically created |
| 59 | + when a client is connected. The `board_id` and |
| 60 | + `participant_id` attributes are set outside of this |
| 61 | + class, when the client is registered to the board. |
| 62 | + """ |
| 63 | + |
| 64 | + def closed(self, code, reason): |
| 65 | + """ |
| 66 | + Called whenever the websocket connection is terminated, |
| 67 | + whether abruptly or normally. |
| 68 | + """ |
| 69 | + bus.websockets.unregister_participant(self.board_id, |
| 70 | + self.participant_id) |
| 71 | + |
| 72 | + def received_message(self, m): |
| 73 | + """ |
| 74 | + Received messages contain events from the |
| 75 | + user interface, like coordinates. They are |
| 76 | + dispatched to all registered participants on this |
| 77 | + board. |
| 78 | + """ |
| 79 | + bus.websockets.broadcast_state(self.board_id, |
| 80 | + self.participant_id, |
| 81 | + m) |
| 82 | + |
| 83 | +class DrawingBoardWebSocketPlugin(WebSocketPlugin): |
| 84 | + def __init__(self, bus): |
| 85 | + """ |
| 86 | + This plugin is the board controller. It keeps |
| 87 | + track of all boards and their registered participants. |
| 88 | +
|
| 89 | + You may access the global instance of this plugin |
| 90 | + through the `bus.websockets` attribute. |
| 91 | + """ |
| 92 | + WebSocketPlugin.__init__(self, bus) |
| 93 | + |
| 94 | + # every 30s, we check if we have dead boards |
| 95 | + # and we clean them |
| 96 | + plugins.Monitor(bus, self.drop_dead_boards, 30).subscribe() |
| 97 | + |
| 98 | + # board index to quickly retrieve |
| 99 | + # clients of a given board |
| 100 | + self.boards = {} |
| 101 | + |
| 102 | + def drop_dead_boards(self): |
| 103 | + """ |
| 104 | + Iterate over all boards and unregister any |
| 105 | + that seem to be unused anymore (because it doesn't |
| 106 | + have any connected client anymore), or that |
| 107 | + have been created for too long (no more than than 5mn |
| 108 | + are allowed). |
| 109 | + """ |
| 110 | + for board_id in self.boards.copy(): |
| 111 | + board = self.boards[board_id] |
| 112 | + if not board['handlers']: |
| 113 | + self.unregister_board(board_id) |
| 114 | + elif (time.time() - board['created']) > 300: |
| 115 | + for ws in board['handlers'].itervalues(): |
| 116 | + if not ws.terminated: |
| 117 | + ws.close(1001, "Board can't exist for more than 5mn") |
| 118 | + self.unregister_board(board_id) |
| 119 | + |
| 120 | + def register_board(self, board_id): |
| 121 | + """ |
| 122 | + Register a board and initialize it |
| 123 | + so that it'll accept participants. |
| 124 | + """ |
| 125 | + if board_id not in self.boards: |
| 126 | + self.bus.log("Registering board %s" % board_id) |
| 127 | + self.boards[board_id] = {'handlers': {}, 'created': time.time()} |
| 128 | + |
| 129 | + def unregister_board(self, board_id): |
| 130 | + """ |
| 131 | + Unregister a board and make it unusable. |
| 132 | + """ |
| 133 | + self.boards.pop(board_id, None) |
| 134 | + |
| 135 | + def register_participant(self, board_id, participant_id, ws_handler): |
| 136 | + """ |
| 137 | + Register a participant to the given board. |
| 138 | + We also set the `board_id` and `participant_id` |
| 139 | + attributes on the websocket handler instance. |
| 140 | + """ |
| 141 | + if board_id in self.boards: |
| 142 | + self.bus.log("Registering participant %s to board %s" % (participant_id, board_id)) |
| 143 | + ws_handler.board_id = board_id |
| 144 | + ws_handler.participant_id = participant_id |
| 145 | + self.boards[board_id]['handlers'][participant_id] = ws_handler |
| 146 | + |
| 147 | + def unregister_participant(self, board_id, participant_id): |
| 148 | + """ |
| 149 | + Unregister a participant from this board. |
| 150 | +
|
| 151 | + Usually this is called automatically when the client |
| 152 | + has closed its connection. |
| 153 | + """ |
| 154 | + if board_id in self.boards: |
| 155 | + board = self.boards[board_id] |
| 156 | + if participant_id in self.boards[board_id]: |
| 157 | + board['handlers'].pop(participant_id, None) |
| 158 | + self.bus.log("Unregistering participant %s from board %s" % (participant_id, board_id)) |
| 159 | + |
| 160 | + def broadcast_state(self, board_id, from_participant_id, state): |
| 161 | + """ |
| 162 | + Dispatch the given `state` to all registered participants |
| 163 | + of the board (except the sender itself of course). |
| 164 | + """ |
| 165 | + if board_id not in self.boards: |
| 166 | + return |
| 167 | + |
| 168 | + board = self.boards[board_id] |
| 169 | + |
| 170 | + for (participant_id, ws) in board['handlers'].iteritems(): |
| 171 | + if from_participant_id != participant_id: |
| 172 | + if not ws.terminated: |
| 173 | + ws.send(state) |
| 174 | + |
| 175 | + |
| 176 | +def render_template(template): |
| 177 | + """ |
| 178 | + Renders a mako template to HTML and |
| 179 | + sets the CherryPy response's body with it. |
| 180 | + """ |
| 181 | + if cherrypy.response.status > 399: |
| 182 | + return |
| 183 | + |
| 184 | + data = cherrypy.response.body or {} |
| 185 | + template = lookup.get_template(template) |
| 186 | + |
| 187 | + if template and isinstance(data, dict): |
| 188 | + cherrypy.response.body = template.render(**data) |
| 189 | + |
| 190 | +# Creating our tool so that they can be |
| 191 | +# used below in the CherryPy applications |
| 192 | +cherrypy.tools.render = cherrypy.Tool('before_finalize', render_template) |
| 193 | +cherrypy.tools.websocket = WebSocketTool() |
| 194 | + |
| 195 | + |
| 196 | + |
| 197 | + |
| 198 | + |
| 199 | +# Web Application |
| 200 | +class SharedDrawingBoardApp(object): |
| 201 | + @cherrypy.expose |
| 202 | + @cherrypy.tools.render(template='index.html') |
| 203 | + def index(self): |
| 204 | + return {'boardid': str(uuid.uuid4())[:4]} |
| 205 | + |
| 206 | + @cherrypy.expose |
| 207 | + @cherrypy.tools.render(template='board.html') |
| 208 | + def board(self, board_id): |
| 209 | + bus.websockets.register_board(board_id) |
| 210 | + return {'boardid': board_id, |
| 211 | + 'participantid': str(uuid.uuid4())[:6]} |
| 212 | + |
| 213 | +# WebSocket endpoint |
| 214 | +class SharedDrawingBoarWebSocketApp(object): |
| 215 | + @cherrypy.expose |
| 216 | + @cherrypy.tools.websocket(handler_cls=DrawingBoardWebSocketHandler) |
| 217 | + def index(self, board_id, participant_id): |
| 218 | + bus.websockets.register_participant(board_id, participant_id, |
| 219 | + cherrypy.request.ws_handler) |
| 220 | + |
| 221 | +if __name__ == '__main__': |
| 222 | + bus.websockets = DrawingBoardWebSocketPlugin(bus) |
| 223 | + bus.websockets.subscribe() |
| 224 | + |
| 225 | + cherrypy.config.update({ |
| 226 | + 'server.socket_host': '0.0.0.0', |
| 227 | + 'server.thread_pool': 30, |
| 228 | + 'log.screen': False, |
| 229 | + 'log.access_file': os.path.join(cwd_dir, 'access.log'), |
| 230 | + 'log.error_file': os.path.join(cwd_dir, 'error.log'), |
| 231 | + 'tools.staticfile.root': cwd_dir, |
| 232 | + 'tools.staticdir.root': cwd_dir, |
| 233 | + #'tools.proxy.on': True, |
| 234 | + #'tools.proxy.base': 'http://yourhost', |
| 235 | + 'error_page.404': os.path.join(cwd_dir, "templates", "404.html") |
| 236 | + }) |
| 237 | + |
| 238 | + app = SharedDrawingBoardApp() |
| 239 | + app.ws = SharedDrawingBoarWebSocketApp() |
| 240 | + |
| 241 | + cherrypy.tree.mount(app, '/', { |
| 242 | + '/': { |
| 243 | + 'tools.encode.on': False, |
| 244 | + 'tools.response_headers.on': True, |
| 245 | + }, |
| 246 | + '/robots.txt': { |
| 247 | + 'tools.staticfile.on': True, |
| 248 | + 'tools.staticfile.filename': 'static/robots.txt' |
| 249 | + }, |
| 250 | + '/static': { |
| 251 | + 'tools.staticdir.on': True, |
| 252 | + 'tools.staticdir.dir': 'static' |
| 253 | + } |
| 254 | + }) |
| 255 | + |
| 256 | + bus.signals.subscribe() |
| 257 | + bus.start() |
| 258 | + bus.block() |
| 259 | + |
| 260 | + |
| 261 | + |
| 262 | + |
0 commit comments