WSGI, Twisted and Server Sent Events
Old-school web applications were easy to create. Big powerful frameworks like Django give you a lot of tools you can leverage. One weak point of all those WSGI framework is that they didn't integrate well with anything that broke outside the usual request-response cycle.
The usual approach nowadays is to use WebSockets for real-time communication between browser clients and web servers. The usual way to do that would be to use a server capable of handling many concurrent connections, and use a message bus from the WSGI app to communicate to that service. That is a lot of moving parts.
In my use case where I build a lot of intranet applications, deploying and maintaining all this infrastructure is a very big burden, so the result is usually to not even explore this kind of functionality.
However, given that I deploy on Twisted, I wanted to explore what kind of cool things I could build on it.
Enter SSE
Server-Sent Events aren't that new - they have just been shadowed by WebSockets. They are a simple data format that is send from the server to the client via a plain HTTP connection. The Javascript API is quite simple, and it even handles retries for you. It's compatible with a lot of recent browsers, but I haven't really done a lot of research on it.
Sample Code
Here is a very simple WSGI app (using bottle.py). It just has a form and a form POST handler.
| from bottle import Bottle, template, request, run | |
| app = Bottle() | |
| @app.route('/hello/') | |
| def greet(): | |
| return template(''' | |
| <html> | |
| <body> | |
| Please introduce yourself: | |
| <form action="/knock" method="POST"> | |
| <input type="text" name="name" /> | |
| <input type="submit" /> | |
| </body> | |
| </html>''', name=name) | |
| @app.post('/knock') | |
| def knock(): | |
| name = request.forms.get('name') | |
| # this is the only new code added in our wsgi app | |
| app.broadcast_message("{} knocked".format(name)) | |
| return template("<p>{{ name }} Knocked!</p>", name=name) | |
| wsgi_app = app | |
| if __name__ == '__main__': | |
| run(app, host='localhost', port=8005) | |
And a basic twisted server to run it:
| # crochet allows non-twisted apps to call twisted code | |
| import crochet | |
| crochet.no_setup() | |
| from twisted.application import internet, service | |
| from twisted.web import server, wsgi, static, resource | |
| from twisted.internet import reactor | |
| from twisted.python import threadpool | |
| # boilerplate to get any WSGI app running under twisted | |
| class WsgiRoot(resource.Resource): | |
| def __init__(self, wsgi_resource): | |
| resource.Resource.__init__(self) | |
| self.wsgi_resource = wsgi_resource | |
| def getChild(self, path, request): | |
| path0 = request.prepath.pop(0) | |
| request.postpath.insert(0, path0) | |
| return self.wsgi_resource | |
| # create a twisted.web resource from a WSGI app. | |
| def get_wsgi_resource(wsgi_app): | |
| pool = threadpool.ThreadPool() | |
| pool.start() | |
| # Allow Ctrl-C to get you out cleanly: | |
| reactor.addSystemEventTrigger('after', 'shutdown', pool.stop) | |
| wsgi_resource = wsgi.WSGIResource(reactor, pool, wsgi_app) | |
| return wsgi_resource | |
| def start(): | |
| # create an SSE resource that is effectively a singleton | |
| from sse import SSEResource | |
| sse_resource = SSEResource() | |
| # attach its "broadcast_message" function to the WSGI app | |
| from app import wsgi_app | |
| wsgi_app.broadcast_message = sse_resource.broadcast_message_async | |
| # serve everything together | |
| root = WsgiRoot(get_wsgi_resource(wsgi_app)) # WSGI is the root | |
| root.putChild("index.html", static.File("index.html")) # serve a static file | |
| root.putChild("sse", sse_resource) # serve the SSE handler | |
| main_site = server.Site(root) | |
| server = internet.TCPServer(8005, main_site) | |
| application = service.Application("twisted_wsgi_sse_integration") | |
| server.setServiceParent(application) | |
| return application | |
| application = start() | |
| # run this using twistd -ny server.py |
And a SSE-savvy twisted.web resource:
| import crochet | |
| crochet.setup() | |
| from twisted.web import resource, server | |
| import random | |
| from datetime import datetime | |
| import json | |
| def _format_sse(msg, event=None): | |
| data = [] | |
| if event is not None: | |
| data.append("event: {}\n".format(event)) | |
| for line in msg.splitlines(): | |
| data.append("data: {}\n".format(line)) | |
| data.append("\n") | |
| return "".join(data) | |
| class SSEResource(resource.Resource): | |
| def __init__(self): | |
| resource.Resource.__init__(self) | |
| self._listeners = [] | |
| def add_listener(self, request): | |
| print "listener connected", request | |
| self._listeners.append(request) | |
| request.notifyFinish().addBoth(self.remove_listener, request) | |
| def remove_listener(self, reason, listener): | |
| print "listener disconnected", listener, "reason", reason | |
| self._listeners.remove(listener) | |
| @crochet.run_in_reactor | |
| def broadcast_message(self, msg, event=None): | |
| self.broadcast_message_async(msg, event) | |
| def broadcast_message_async(self, msg, event=None): | |
| sse = _format_sse(msg, event) | |
| for listener in self._listeners: | |
| listener.write(sse) | |
| def render_GET(self, request): | |
| request.setHeader("Content-Type", "text/event-stream") | |
| self.add_listener(request) | |
| return server.NOT_DONE_YET | |
And a very simple index.html
| <html> | |
| <head> | |
| <script type="text/javascript"> | |
| var evtSource = new EventSource("/sse"); | |
| evtSource.onmessage = function(e) { | |
| // onmessage is the generic handler | |
| var eventList = document.getElementById("eventlist"); | |
| var newElement = document.createElement("li"); | |
| newElement.innerHTML = "message: " + e.data; | |
| eventList.appendChild(newElement); | |
| } | |
| evtSource.addEventListener("ping", function(e) { | |
| // addEventListener can be used for fine-tuning | |
| var eventList = document.getElementById("eventlist"); | |
| var newElement = document.createElement("li"); | |
| var obj = JSON.parse(e.data); | |
| newElement.innerHTML = "ping at " + obj.time; | |
| eventList.appendChild(newElement); | |
| }); | |
| </script> | |
| </head> | |
| <body> | |
| <h1>Twisted WSGI Integration</h1> | |
| <ol id="eventlist"> | |
| </ol> | |
| </body> | |
| </html> |
How it works
The WSGI app just calls some Python code. Through crochet we ensure that it gets back a useful result (though in this case, we just throw it away). We use a plain POST to send data to the server. Converting that to an AJAX request is left as an exercise to the reader. The SSE handler is a singleton that keeps track of all the listeners that are connected to it, and broadcasts messages to it.
Does it scale?
It should! I have no experience running Twisted Web under heavy load but it's more than enough for intranet-style apps (even when I have 50 machines hitting some API endpoints quite frequently). If someone wants to run some testing, please get in touch.
What's next?
I would like to make this a bit more reusable, with some better discovery than the current "inject a global function into the namespace". Also, Django integration is something I'd like to investigate. And why not try if the same approach can be extended to web sockets as well?