├── CHANGELOG.md ├── lib ├── src │ ├── proxy_sink.dart │ ├── event.dart │ ├── encoder.dart │ ├── event_cache.dart │ └── decoder.dart ├── io_server.dart ├── publisher.dart └── eventsource.dart ├── .gitignore ├── pubspec.yaml ├── example ├── client_generic.dart └── client_browser.dart ├── LICENSE ├── README.md └── test └── codec_test.dart /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 (2016-11-27) 4 | 5 | - Initial version 6 | -------------------------------------------------------------------------------- /lib/src/proxy_sink.dart: -------------------------------------------------------------------------------- 1 | library eventsource.src.proxy_sink; 2 | 3 | /// Just a simple [Sink] implementation that proxies the [add] and [close] 4 | /// methods. 5 | class ProxySink implements Sink { 6 | Function onAdd; 7 | Function onClose; 8 | ProxySink({this.onAdd, this.onClose}); 9 | @override 10 | void add(t) => onAdd(t); 11 | @override 12 | void close() => onClose(); 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .packages 3 | .pub/ 4 | build/ 5 | packages 6 | # Remove the following pattern if you wish to check in your lock file 7 | pubspec.lock.old 8 | 9 | # Files created by dart2js 10 | *.dart.js 11 | *.part.js 12 | *.js.deps 13 | *.js.map 14 | *.info.json 15 | 16 | # Directory created by dartdoc 17 | doc/api/ 18 | 19 | # JetBrains IDEs 20 | .idea/ 21 | *.iml 22 | *.ipr 23 | *.iws 24 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: eventsource 2 | description: A client and server implementation of Server-Sent Events. 3 | version: 0.2.1 4 | author: Steven Roose 5 | homepage: https://github.com/stevenroose/dart-eventsource 6 | 7 | environment: 8 | sdk: ">=1.0.0 <3.0.0" 9 | 10 | dependencies: 11 | collection: ">=1.4.1 <2.0.0" 12 | http: ">=0.11.0 <0.13.0" 13 | http_parser: ">=2.2.0 <4.0.0" 14 | logging: ">=0.11.0 <0.12.0" 15 | sync: ">=0.1.0 <0.3.0" 16 | 17 | dev_dependencies: 18 | test: ">=0.12.0 <2.0.0" 19 | -------------------------------------------------------------------------------- /lib/src/event.dart: -------------------------------------------------------------------------------- 1 | library eventsource.src.event; 2 | 3 | class Event implements Comparable { 4 | /// An identifier that can be used to allow a client to replay 5 | /// missed Events by returning the Last-Event-Id header. 6 | /// Return empty string if not required. 7 | String id; 8 | 9 | /// The name of the event. Return empty string if not required. 10 | String event; 11 | 12 | /// The payload of the event. 13 | String data; 14 | 15 | Event({this.id, this.event, this.data}); 16 | 17 | Event.message({this.id, this.data}) : event = "message"; 18 | 19 | @override 20 | int compareTo(Event other) => id.compareTo(other.id); 21 | } 22 | -------------------------------------------------------------------------------- /example/client_generic.dart: -------------------------------------------------------------------------------- 1 | import "package:eventsource/eventsource.dart"; 2 | 3 | main() async { 4 | // Because EventSource uses the http package, all platforms for which http 5 | // works, will be able to use the generic method: 6 | 7 | EventSource eventSource = 8 | await EventSource.connect("http://example.org/events"); 9 | // listen for events 10 | eventSource.listen((Event event) { 11 | print("New event:"); 12 | print(" event: ${event.event}"); 13 | print(" data: ${event.data}"); 14 | }); 15 | 16 | // If you know the last event.id from a previous connection, you can try this: 17 | 18 | String lastId = "iknowmylastid"; 19 | eventSource = await EventSource.connect("http://example.org/events", 20 | lastEventId: lastId); 21 | // listen for events 22 | eventSource.listen((Event event) { 23 | print("New event:"); 24 | print(" event: ${event.event}"); 25 | print(" data: ${event.data}"); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Steven Roose 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /example/client_browser.dart: -------------------------------------------------------------------------------- 1 | import "package:eventsource/eventsource.dart"; 2 | import "package:http/browser_client.dart"; 3 | 4 | main() async { 5 | // Because EventSource uses the http package, browser usage needs a special 6 | // approach. This will change once https://github.com/dart-lang/http/issues/1 7 | // is fixed. 8 | 9 | EventSource eventSource = await EventSource 10 | .connect("http://example.org/events", client: new BrowserClient()); 11 | // listen for events 12 | eventSource.listen((Event event) { 13 | print("New event:"); 14 | print(" event: ${event.event}"); 15 | print(" data: ${event.data}"); 16 | }); 17 | 18 | // If you know the last event.id from a previous connection, you can try this: 19 | 20 | String lastId = "iknowmylastid"; 21 | eventSource = await EventSource.connect( 22 | "http://example.org/events", 23 | client: new BrowserClient(), 24 | lastEventId: lastId, 25 | ); 26 | // listen for events 27 | eventSource.listen((Event event) { 28 | print("New event:"); 29 | print(" event: ${event.event}"); 30 | print(" data: ${event.data}"); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eventsource 2 | 3 | A library for using EventSource or Server-Sent Events (SSE). 4 | Both client and server functionality is provided. 5 | 6 | This library implements the interface as described [here](https://html.spec.whatwg.org/multipage/comms.html#server-sent-events). 7 | 8 | ## Client usage 9 | 10 | For more advanced usage, see the `example/` directory. 11 | Creating a new EventSource client is as easy as a single call. 12 | The http package is used under the hood, so wherever this package works, this lbirary will also work. 13 | Browser usage is slightly different. 14 | 15 | ```dart 16 | EventSource eventSource = await EventSource.connect("http://example.com/events"); 17 | // in browsers, you need to pass a http.BrowserClient: 18 | EventSource eventSource = await EventSource.connect("http://example.com/events", 19 | client: new http.BrowserClient()); 20 | ``` 21 | 22 | ## Server usage 23 | 24 | We recommend using [`shelf_eventsource`](https://pub.dartlang.org/packages/shelf_eventsource) for 25 | serving Server-Sent Events. 26 | This library provides an `EventSourcePublisher` that manages subscriptions, channels, encoding. 27 | We refer to documentation in the [`shelf_eventsource`](https://pub.dartlang.org/packages/shelf_eventsource) 28 | package for more information. 29 | 30 | This library also includes a server provider for `dart:io`'s `HttpServer` in `io_server.dart`. 31 | However, it has some issues with data flushing that are yet to be resolved, so we recommend using 32 | shelf instead. 33 | 34 | ## Licensing 35 | 36 | This project is available under the MIT license, as can be found in the LICENSE file. -------------------------------------------------------------------------------- /lib/src/encoder.dart: -------------------------------------------------------------------------------- 1 | library eventsource.src.encoder; 2 | 3 | import "dart:convert"; 4 | import "dart:io"; 5 | 6 | import "event.dart"; 7 | import "proxy_sink.dart"; 8 | 9 | class EventSourceEncoder extends Converter> { 10 | final bool compressed; 11 | 12 | const EventSourceEncoder({bool this.compressed: false}); 13 | 14 | static Map _fields = { 15 | "id: ": (e) => e.id, 16 | "event: ": (e) => e.event, 17 | "data: ": (e) => e.data, 18 | }; 19 | 20 | @override 21 | List convert(Event event) { 22 | String payload = convertToString(event); 23 | List bytes = utf8.encode(payload); 24 | if (compressed) { 25 | bytes = GZIP.encode(bytes); 26 | } 27 | return bytes; 28 | } 29 | 30 | String convertToString(Event event) { 31 | String payload = ""; 32 | for (String prefix in _fields.keys) { 33 | String value = _fields[prefix](event); 34 | if (value == null || value.isEmpty) { 35 | continue; 36 | } 37 | // multi-line values need the field prefix on every line 38 | value = value.replaceAll("\n", "\n$prefix"); 39 | payload += prefix + value + "\n"; 40 | } 41 | payload += "\n"; 42 | return payload; 43 | } 44 | 45 | @override 46 | Sink startChunkedConversion(Sink> sink) { 47 | Sink inputSink = sink; 48 | if (compressed) { 49 | inputSink = GZIP.encoder.startChunkedConversion(inputSink); 50 | } 51 | inputSink = utf8.encoder.startChunkedConversion(inputSink); 52 | return new ProxySink( 53 | onAdd: (Event event) => inputSink.add(convertToString(event)), 54 | onClose: () => inputSink.close()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/src/event_cache.dart: -------------------------------------------------------------------------------- 1 | library eventsource.src.cache; 2 | 3 | import "package:collection/collection.dart"; 4 | 5 | import "event.dart"; 6 | 7 | //TODO use more efficient data structure than List 8 | class EventCache { 9 | final int cacheCapacity; 10 | final bool comparableIds; 11 | Map> _caches = new Map>(); 12 | 13 | EventCache({this.cacheCapacity, this.comparableIds: true}); 14 | 15 | void replay(Sink sink, String lastEventId, [String channel = ""]) { 16 | List cache = _caches[channel]; 17 | if (cache == null || cache.isEmpty) { 18 | // nothing to replay 19 | return; 20 | } 21 | // find the location of lastEventId in the queue 22 | int index; 23 | if (comparableIds) { 24 | // if comparableIds, we can use binary search 25 | index = binarySearch(cache, lastEventId); 26 | } else { 27 | // otherwise, we starts from the last one and look one by one 28 | index = cache.length - 1; 29 | while (index > 0 && cache[index].id != lastEventId) { 30 | index--; 31 | } 32 | } 33 | if (index >= 0) { 34 | // add them all to the sink 35 | cache.sublist(index).forEach(sink.add); 36 | } 37 | } 38 | 39 | /// Add a new [Event] to the cache(s) of the specified channel(s). 40 | /// Please note that we assume events are added with increasing values of 41 | /// [Event.id]. 42 | void add(Event event, [Iterable channels = const [""]]) { 43 | for (String channel in channels) { 44 | List cache = _caches.putIfAbsent(channel, () => new List()); 45 | if (cacheCapacity != null && cache.length >= cacheCapacity) { 46 | cache.removeAt(0); 47 | } 48 | cache.add(event); 49 | } 50 | } 51 | 52 | void clear([Iterable channels = const [""]]) { 53 | channels.forEach(_caches.remove); 54 | } 55 | 56 | void clearAll() { 57 | _caches.clear(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/io_server.dart: -------------------------------------------------------------------------------- 1 | library eventsource.io_server; 2 | 3 | import "dart:io" as io; 4 | 5 | import "package:sync/waitgroup.dart"; 6 | 7 | import "publisher.dart"; 8 | import "src/event.dart"; 9 | import "src/encoder.dart"; 10 | 11 | /// Create a handler to serve [io.HttpRequest] objects for the specified 12 | /// channel. 13 | /// This method can be passed to the [io.HttpServer.listen] method. 14 | Function createIoHandler(EventSourcePublisher publisher, 15 | {String channel: "", bool gzip: false}) { 16 | void ioHandler(io.HttpRequest request) { 17 | io.HttpResponse response = request.response; 18 | 19 | // set content encoding to gzip if we allow it and the request supports it 20 | bool useGzip = gzip && 21 | (request.headers.value(io.HttpHeaders.ACCEPT_ENCODING) ?? "") 22 | .contains("gzip"); 23 | 24 | // set headers and status code 25 | response.statusCode = 200; 26 | response.headers.set("Content-Type", "text/event-stream; charset=utf-8"); 27 | response.headers 28 | .set("Cache-Control", "no-cache, no-store, must-revalidate"); 29 | response.headers.set("Connection", "keep-alive"); 30 | if (useGzip) response.headers.set("Content-Encoding", "gzip"); 31 | // a wait group to keep track of flushes in order not to close while 32 | // flushing 33 | WaitGroup flushes = new WaitGroup(); 34 | // flush the headers 35 | flushes.add(1); 36 | response.flush().then((_) => flushes.done()); 37 | 38 | // create encoder for this connection 39 | var encodedSink = new EventSourceEncoder(compressed: useGzip) 40 | .startChunkedConversion(response); 41 | 42 | // define the methods for pushing events and closing the connection 43 | void onEvent(Event event) { 44 | encodedSink.add(event); 45 | flushes.add(1); 46 | response.flush().then((_) => flushes.done()); 47 | } 48 | 49 | void onClose() { 50 | flushes.wait().then((_) => response.close()); 51 | } 52 | 53 | // initialize the new subscription 54 | publisher.newSubscription( 55 | onEvent: onEvent, 56 | onClose: onClose, 57 | channel: channel, 58 | lastEventId: request.headers.value("Last-Event-ID")); 59 | } 60 | 61 | return ioHandler; 62 | } 63 | -------------------------------------------------------------------------------- /test/codec_test.dart: -------------------------------------------------------------------------------- 1 | library codec_test; 2 | 3 | import "dart:async"; 4 | import "dart:convert"; 5 | 6 | import "package:test/test.dart"; 7 | 8 | import "package:eventsource/src/decoder.dart"; 9 | import "package:eventsource/src/encoder.dart"; 10 | import "package:eventsource/src/event.dart"; 11 | 12 | Map _VECTORS = { 13 | new Event(id: "1", event: "Add", data: "This is a test"): 14 | "id: 1\nevent: Add\ndata: This is a test\n\n", 15 | new Event(data: "This message, it\nhas two lines."): 16 | "data: This message, it\ndata: has two lines.\n\n", 17 | }; 18 | 19 | void main() { 20 | group("encoder", () { 21 | test("vectors", () { 22 | var encoder = new EventSourceEncoder(); 23 | for (Event event in _VECTORS.keys) { 24 | var encoded = _VECTORS[event]; 25 | expect(encoder.convert(event), equals(utf8.encode(encoded))); 26 | } 27 | }); 28 | //TODO add gzip test 29 | }); 30 | 31 | group("decoder", () { 32 | test("vectors", () async { 33 | for (Event event in _VECTORS.keys) { 34 | var encoded = _VECTORS[event]; 35 | var stream = new Stream.fromIterable([encoded]) 36 | .transform(new Utf8Encoder()) 37 | .transform(new EventSourceDecoder()); 38 | stream.listen(expectAsync((decodedEvent) { 39 | expect(decodedEvent.id, equals(event.id)); 40 | expect(decodedEvent.event, equals(event.event)); 41 | expect(decodedEvent.data, equals(event.data)); 42 | }, count: 1)); 43 | } 44 | }); 45 | test("pass retry value", () async { 46 | Event event = new Event(id: "1", event: "Add", data: "This is a test"); 47 | String encodedWithRetry = 48 | "id: 1\nevent: Add\ndata: This is a test\nretry: 100\n\n"; 49 | var changeRetryValue = expectAsync((Duration value) { 50 | expect(value.inMilliseconds, equals(100)); 51 | }, count: 1); 52 | var stream = new Stream.fromIterable([encodedWithRetry]) 53 | .transform(new Utf8Encoder()) 54 | .transform(new EventSourceDecoder(retryIndicator: changeRetryValue)); 55 | stream.listen(expectAsync((decodedEvent) { 56 | expect(decodedEvent.id, equals(event.id)); 57 | expect(decodedEvent.event, equals(event.event)); 58 | expect(decodedEvent.data, equals(event.data)); 59 | }, count: 1)); 60 | }); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/decoder.dart: -------------------------------------------------------------------------------- 1 | library eventsource.src.decoder; 2 | 3 | import "dart:async"; 4 | import "dart:convert"; 5 | 6 | import "event.dart"; 7 | 8 | typedef RetryIndicator = void Function(Duration retry); 9 | 10 | class EventSourceDecoder implements StreamTransformer, Event> { 11 | RetryIndicator retryIndicator; 12 | 13 | EventSourceDecoder({this.retryIndicator}); 14 | 15 | Stream bind(Stream> stream) { 16 | StreamController controller; 17 | controller = new StreamController(onListen: () { 18 | // the event we are currently building 19 | Event currentEvent = new Event(); 20 | // the regexes we will use later 21 | RegExp lineRegex = new RegExp(r"^([^:]*)(?::)?(?: )?(.*)?$"); 22 | RegExp removeEndingNewlineRegex = new RegExp(r"^((?:.|\n)*)\n$"); 23 | // This stream will receive chunks of data that is not necessarily a 24 | // single event. So we build events on the fly and broadcast the event as 25 | // soon as we encounter a double newline, then we start a new one. 26 | stream 27 | .transform(new Utf8Decoder()) 28 | .transform(new LineSplitter()) 29 | .listen((String line) { 30 | if (line.isEmpty) { 31 | // event is done 32 | // strip ending newline from data 33 | if (currentEvent.data != null) { 34 | var match = removeEndingNewlineRegex.firstMatch(currentEvent.data); 35 | currentEvent.data = match.group(1); 36 | } 37 | controller.add(currentEvent); 38 | currentEvent = new Event(); 39 | return; 40 | } 41 | // match the line prefix and the value using the regex 42 | Match match = lineRegex.firstMatch(line); 43 | String field = match.group(1); 44 | String value = match.group(2) ?? ""; 45 | if (field.isEmpty) { 46 | // lines starting with a colon are to be ignored 47 | return; 48 | } 49 | switch (field) { 50 | case "event": 51 | currentEvent.event = value; 52 | break; 53 | case "data": 54 | currentEvent.data = (currentEvent.data ?? "") + value + "\n"; 55 | break; 56 | case "id": 57 | currentEvent.id = value; 58 | break; 59 | case "retry": 60 | if (retryIndicator != null) { 61 | retryIndicator(new Duration(milliseconds: int.parse(value))); 62 | } 63 | break; 64 | } 65 | }); 66 | }); 67 | return controller.stream; 68 | } 69 | 70 | StreamTransformer cast () => StreamTransformer.castFrom, Event, RS, RT>(this); 71 | } 72 | -------------------------------------------------------------------------------- /lib/publisher.dart: -------------------------------------------------------------------------------- 1 | library eventsource.server; 2 | 3 | export "src/event.dart"; 4 | 5 | import "dart:async"; 6 | 7 | import "package:logging/logging.dart" as log; 8 | 9 | import "src/event.dart"; 10 | import "src/event_cache.dart"; 11 | import "src/proxy_sink.dart"; 12 | 13 | /// An EventSource publisher. It can manage different channels of events. 14 | /// This class forms the backbone of an EventSource server. To actually serve 15 | /// a web server, use this together with [shelf_eventsource] or another server 16 | /// implementation. 17 | class EventSourcePublisher extends Sink { 18 | log.Logger logger; 19 | EventCache _cache; 20 | 21 | /// Create a new EventSource server. 22 | /// 23 | /// When using a cache, for efficient replaying, it is advisable to use a 24 | /// custom Event implementation that overrides the `Event.compareTo` method. 25 | /// F.e. if integer events are used, sorting should be done on integers and 26 | /// not on the string representations of them. 27 | /// If your Event's id properties are not incremental using 28 | /// [Comparable.compare], set [comparableIds] to false. 29 | EventSourcePublisher( 30 | {int cacheCapacity: 0, 31 | bool comparableIds: false, 32 | bool enableLogging: true}) { 33 | if (cacheCapacity > 0) { 34 | _cache = new EventCache(cacheCapacity: cacheCapacity); 35 | } 36 | if (enableLogging) { 37 | logger = new log.Logger("EventSourceServer"); 38 | } 39 | } 40 | 41 | Map> _subsByChannel = {}; 42 | 43 | /// Creates a Sink for the specified channel. 44 | /// The `add` and `remove` methods of this channel are equivalent to the 45 | /// respective methods of this class with the specific channel passed along. 46 | Sink channel(String channel) => new ProxySink( 47 | onAdd: (e) => add(e, channels: [channel]), 48 | onClose: () => close(channels: [channel])); 49 | 50 | /// Add a publication to the specified channels. 51 | /// By default, only adds to the default channel. 52 | @override 53 | void add(Event event, {Iterable channels: const [""]}) { 54 | for (String channel in channels) { 55 | List subs = _subsByChannel[channel]; 56 | if (subs == null) { 57 | continue; 58 | } 59 | _logFiner( 60 | "Sending event on channel $channel to ${subs.length} subscribers."); 61 | for (var sub in subs) { 62 | sub.add(event); 63 | } 64 | } 65 | if (_cache != null) { 66 | _cache.add(event, channels); 67 | } 68 | } 69 | 70 | /// Close the specified channels. 71 | /// All the connections with the subscribers to this channels will be closed. 72 | /// By default only closes the default channel. 73 | @override 74 | void close({Iterable channels: const [""]}) { 75 | for (String channel in channels) { 76 | List subs = _subsByChannel[channel]; 77 | if (subs == null) { 78 | continue; 79 | } 80 | _logInfo("Closing channel $channel with ${subs.length} subscribers."); 81 | for (var sub in subs) { 82 | sub.close(); 83 | } 84 | } 85 | if (_cache != null) { 86 | _cache.clear(channels); 87 | } 88 | } 89 | 90 | /// Close all the open channels. 91 | void closeAllChannels() => close(channels: _subsByChannel.keys); 92 | 93 | /// Initialize a new subscription and replay when possible. 94 | /// Should not be used by the user directly. 95 | void newSubscription( 96 | {Function onEvent, 97 | Function onClose, 98 | String channel, 99 | String lastEventId}) { 100 | _logFine("New subscriber on channel $channel."); 101 | // create a sink for the subscription 102 | var sub = new ProxySink(onAdd: onEvent, onClose: onClose); 103 | // save the subscription 104 | _subsByChannel.putIfAbsent(channel, () => []).add(sub); 105 | // replay past events 106 | if (_cache != null && lastEventId != null) { 107 | scheduleMicrotask(() { 108 | _logFine("Replaying events on channel $channel from id $lastEventId."); 109 | _cache.replay(sub, lastEventId, channel); 110 | }); 111 | } 112 | } 113 | 114 | void _logInfo(message) { 115 | if (logger != null) { 116 | logger.log(log.Level.INFO, message); 117 | } 118 | } 119 | 120 | void _logFine(message) { 121 | if (logger != null) { 122 | logger.log(log.Level.FINE, message); 123 | } 124 | } 125 | 126 | void _logFiner(message) { 127 | if (logger != null) { 128 | logger.log(log.Level.FINER, message); 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/eventsource.dart: -------------------------------------------------------------------------------- 1 | library eventsource; 2 | 3 | export "src/event.dart"; 4 | 5 | import "dart:async"; 6 | import "dart:convert"; 7 | 8 | import "package:http/http.dart" as http; 9 | import "package:http/src/utils.dart" show encodingForCharset; 10 | import "package:http_parser/http_parser.dart" show MediaType; 11 | 12 | import "src/event.dart"; 13 | import "src/decoder.dart"; 14 | 15 | enum EventSourceReadyState { 16 | CONNECTING, 17 | OPEN, 18 | CLOSED, 19 | } 20 | 21 | class EventSourceSubscriptionException extends Event implements Exception { 22 | int statusCode; 23 | String message; 24 | 25 | @override 26 | String get data => "$statusCode: $message"; 27 | 28 | EventSourceSubscriptionException(this.statusCode, this.message) 29 | : super(event: "error"); 30 | } 31 | 32 | /// An EventSource client that exposes a [Stream] of [Event]s. 33 | class EventSource extends Stream { 34 | // interface attributes 35 | 36 | final Uri url; 37 | 38 | EventSourceReadyState get readyState => _readyState; 39 | 40 | Stream get onOpen => this.where((e) => e.event == "open"); 41 | Stream get onMessage => this.where((e) => e.event == "message"); 42 | Stream get onError => this.where((e) => e.event == "error"); 43 | 44 | // internal attributes 45 | 46 | StreamController _streamController = 47 | new StreamController.broadcast(); 48 | 49 | EventSourceReadyState _readyState = EventSourceReadyState.CLOSED; 50 | 51 | http.Client client; 52 | Duration _retryDelay = const Duration(milliseconds: 3000); 53 | String _lastEventId; 54 | EventSourceDecoder _decoder; 55 | 56 | /// Create a new EventSource by connecting to the specified url. 57 | static Future connect(url, 58 | {http.Client client, String lastEventId}) async { 59 | // parameter initialization 60 | url = url is Uri ? url : Uri.parse(url); 61 | client = client ?? new http.Client(); 62 | lastEventId = lastEventId ?? ""; 63 | EventSource es = new EventSource._internal(url, client, lastEventId); 64 | await es._start(); 65 | return es; 66 | } 67 | 68 | EventSource._internal(this.url, this.client, this._lastEventId) { 69 | _decoder = new EventSourceDecoder(retryIndicator: _updateRetryDelay); 70 | } 71 | 72 | // proxy the listen call to the controller's listen call 73 | @override 74 | StreamSubscription listen(void onData(Event event), 75 | {Function onError, void onDone(), bool cancelOnError}) => 76 | _streamController.stream.listen(onData, 77 | onError: onError, onDone: onDone, cancelOnError: cancelOnError); 78 | 79 | /// Attempt to start a new connection. 80 | Future _start() async { 81 | _readyState = EventSourceReadyState.CONNECTING; 82 | var request = new http.Request("GET", url); 83 | request.headers["Cache-Control"] = "no-cache"; 84 | request.headers["Accept"] = "text/event-stream"; 85 | if (_lastEventId.isNotEmpty) { 86 | request.headers["Last-Event-ID"] = _lastEventId; 87 | } 88 | var response = await client.send(request); 89 | if (response.statusCode != 200) { 90 | // server returned an error 91 | var bodyBytes = await response.stream.toBytes(); 92 | String body = _encodingForHeaders(response.headers).decode(bodyBytes); 93 | throw new EventSourceSubscriptionException(response.statusCode, body); 94 | } 95 | _readyState = EventSourceReadyState.OPEN; 96 | // start streaming the data 97 | response.stream.transform(_decoder).listen((Event event) { 98 | _streamController.add(event); 99 | _lastEventId = event.id; 100 | }, 101 | cancelOnError: true, 102 | onError: _retry, 103 | onDone: () => _readyState = EventSourceReadyState.CLOSED); 104 | } 105 | 106 | /// Retries until a new connection is established. Uses exponential backoff. 107 | Future _retry(dynamic e) async { 108 | _readyState = EventSourceReadyState.CONNECTING; 109 | // try reopening with exponential backoff 110 | Duration backoff = _retryDelay; 111 | while (true) { 112 | await new Future.delayed(backoff); 113 | try { 114 | await _start(); 115 | break; 116 | } catch (error) { 117 | _streamController.addError(error); 118 | backoff *= 2; 119 | } 120 | } 121 | } 122 | 123 | void _updateRetryDelay(Duration retry) { 124 | _retryDelay = retry; 125 | } 126 | } 127 | 128 | /// Returns the encoding to use for a response with the given headers. This 129 | /// defaults to [LATIN1] if the headers don't specify a charset or 130 | /// if that charset is unknown. 131 | Encoding _encodingForHeaders(Map headers) => 132 | encodingForCharset(_contentTypeForHeaders(headers).parameters['charset']); 133 | 134 | /// Returns the [MediaType] object for the given headers's content-type. 135 | /// 136 | /// Defaults to `application/octet-stream`. 137 | MediaType _contentTypeForHeaders(Map headers) { 138 | var contentType = headers['content-type']; 139 | if (contentType != null) return new MediaType.parse(contentType); 140 | return new MediaType("application", "octet-stream"); 141 | } 142 | --------------------------------------------------------------------------------