Repository: bokeh/bokeh

Issue:
Add support for data source using server-sent events
Currently bokeh uses Ajax polling or web sockets to receive data from a backend server. It would be nice to use server-sent events in some situations, since they can simplify the backend-code and are a good option for one way data streaming.


Discussion:

Resolving patch:
diff --git a/bokeh/models/sources.py b/bokeh/models/sources.py
--- a/bokeh/models/sources.py
+++ b/bokeh/models/sources.py
@@ -41,6 +41,7 @@
 #-----------------------------------------------------------------------------
 
 __all__ = (
+    'ServerSentDataSource',
     'AjaxDataSource',
     'CDSView',
     'ColumnarDataSource',
@@ -698,23 +699,61 @@ class GeoJSONDataSource(ColumnarDataSource):
     """)
 
 @abstract
-class RemoteSource(ColumnDataSource):
-    ''' Base class for remote column data sources that can update from data
-    URLs at prescribed time intervals.
+class WebSource(ColumnDataSource):
+    ''' Base class for web column data sources that can update from data
+    URLs.
 
     .. note::
         This base class is typically not useful to instantiate on its own.
 
     '''
 
+    adapter = Instance(CustomJS, help="""
+    A JavaScript callback to adapt raw JSON responses to Bokeh ``ColumnDataSource``
+    format.
+
+    If provided, this callback is executes immediately after the JSON data is
+    received, but before appending or replacing data in the data source. The
+    ``CustomJS`` callback will receive the ``AjaxDataSource`` as ``cb_obj`` and
+    will receive the raw JSON response as ``cb_data.response``. The callback
+    code should return a ``data`` object suitable for a Bokeh ``ColumnDataSource``
+    (i.e.  a mapping of string column names to arrays of data).
+    """)
+
+    max_size = Int(help="""
+    Maximum size of the data columns. If a new fetch would result in columns
+    larger than ``max_size``, then earlier data is dropped to make room.
+    """)
+
+    mode = Enum("replace", "append", help="""
+    Whether to append new data to existing data (up to ``max_size``), or to
+    replace existing data entirely.
+    """)
+
     data_url = String(help="""
     A URL to to fetch data from.
     """)
 
+@abstract
+class RemoteSource(WebSource):
+    ''' Base class for remote column data sources that can update from data
+    URLs at prescribed time intervals.
+
+    .. note::
+        This base class is typically not useful to instantiate on its own.
+
+    '''
+
     polling_interval = Int(help="""
     A polling interval (in milliseconds) for updating data source.
     """)
 
+class ServerSentDataSource(WebSource):
+    ''' A data source that can populate columns by receiving server sent
+    events endpoints.
+
+    '''
+
 class AjaxDataSource(RemoteSource):
     ''' A data source that can populate columns by making Ajax calls to REST
     endpoints.
@@ -746,28 +785,6 @@ class AjaxDataSource(RemoteSource):
     Specify the HTTP method to use for the Ajax request (GET or POST)
     """)
 
-    mode = Enum("replace", "append", help="""
-    Whether to append new data to existing data (up to ``max_size``), or to
-    replace existing data entirely.
-    """)
-
-    adapter = Instance(CustomJS, help="""
-    A JavaScript callback to adapt raw JSON responses to Bokeh ``ColumnDataSource``
-    format.
-
-    If provided, this callback is executes immediately after the JSON data is
-    received, but before appending or replacing data in the data source. The
-    ``CustomJS`` callback will receive the ``AjaxDataSource`` as ``cb_obj`` and
-    will receive the raw JSON response as ``cb_data.response``. The callback
-    code should return a ``data`` object suitable for a Bokeh ``ColumnDataSource``
-    (i.e.  a mapping of string column names to arrays of data).
-    """)
-
-    max_size = Int(help="""
-    Maximum size of the data columns. If a new fetch would result in columns
-    larger than ``max_size``, then earlier data is dropped to make room.
-    """)
-
     if_modified = Bool(False, help="""
     Whether to include an ``If-Modified-Since`` header in Ajax requests
     to the server. If this header is supported by the server, then only
diff --git a/examples/howto/server_sent_events_source.py b/examples/howto/server_sent_events_source.py
new file mode 100644
--- /dev/null
+++ b/examples/howto/server_sent_events_source.py
@@ -0,0 +1,69 @@
+import numpy as np
+import json
+from datetime import datetime
+from time import sleep
+from flask import Flask, make_response, request, Response
+
+from bokeh.plotting import figure, show, output_file
+from bokeh.models import ServerSentDataSource, CustomJS
+
+# Bokeh related code
+
+adapter = CustomJS(code="""
+    const result = {x: [], y: []}
+    const pts = cb_data.response
+    for (i=0; i<pts.length; i++) {
+        result.x.push(pts[i][0])
+        result.y.push(pts[i][1])
+    }
+    return result
+""")
+
+source = ServerSentDataSource(data_url='http://localhost:5050/data', max_size=100,
+                              mode='append', adapter=adapter)
+
+p = figure(plot_height=800, plot_width=800, background_fill_color="lightgrey",
+           title="Streaming via Server Sent Events", x_range=[-5,5], y_range=[-5,5])
+p.circle('x', 'y', source=source)
+
+# Flask related code
+
+app = Flask(__name__)
+
+
+def crossdomain(f):
+    def wrapped_function(*args, **kwargs):
+        resp = make_response(f(*args, **kwargs))
+        h = resp.headers
+        h['Access-Control-Allow-Origin'] = '*'
+        h['Access-Control-Allow-Methods'] = "GET, OPTIONS, POST"
+        h['Access-Control-Max-Age'] = str(21600)
+        requested_headers = request.headers.get('Access-Control-Request-Headers')
+        if requested_headers:
+            h['Access-Control-Allow-Headers'] = requested_headers
+        return resp
+    return wrapped_function
+
+
+@app.route('/data', methods=['GET', 'OPTIONS'])
+@crossdomain
+def stream():
+    def event_stream():
+        """No global state used"""
+        while True:
+            t = datetime.now().timestamp()
+            v = np.sin(t*5) + 0.2*np.random.random() + 3
+            x = v*np.sin(t)
+            y = v*np.cos(t)
+            data = [[x, y]]
+            yield "data: "+json.dumps(data)+"\n\n"
+            sleep(0.1)
+    resp = Response(event_stream(), mimetype="text/event-stream")
+    resp.headers['Cache-Control'] = 'no-cache'
+    return resp
+
+# show and run
+
+output_file("plot.html", title='Bokeh Plot', mode='inline')
+show(p)
+app.run(port=5050)


Test patch: