├── .gitignore
├── Cargo.toml
├── src
├── index.html
└── main.rs
├── drain.js
├── LICENSE
├── README.md
├── benchmark.js
└── Cargo.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | **/*.rs.bk
3 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "actix-sse"
3 | version = "0.1.0"
4 | authors = ["arve"]
5 | edition = "2018"
6 |
7 | [dependencies]
8 | actix-rt = "0.2.5"
9 | actix-web = "1.0"
10 | env_logger = "0.6"
11 | futures = "0.1"
12 | tokio = "0.1"
13 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Server-sent events
7 |
13 |
14 |
15 |
16 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/drain.js:
--------------------------------------------------------------------------------
1 | const http = require('http')
2 |
3 | let drop_goal = 10_000;
4 | let dropped = 0;
5 |
6 | let query = {
7 | host: 'localhost',
8 | port: 8080,
9 | path: '/events'
10 | }
11 |
12 | setInterval(() => {
13 | if (dropped < drop_goal) {
14 | let request = http.get(query, response => {
15 | response.on('data', data => {
16 | if (data.includes("data: connected\n")) {
17 | // drop connection after welcome message
18 | dropped += 1;
19 | request.abort()
20 | }
21 | })
22 | })
23 | .on('error', () => {})
24 | }
25 | }, 1)
26 |
27 | setInterval(() => {
28 | http.get('http://localhost:8080/', () => print_status(true))
29 | .setTimeout(100, () => print_status(false))
30 | .on('error', () => {})
31 | }, 20)
32 |
33 | function print_status(accepting_connections) {
34 | process.stdout.write("\r\x1b[K");
35 | process.stdout.write(`Connections dropped: ${dropped}, accepting connections: ${accepting_connections}`);
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Arve Seljebu
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # actix-sse
2 | Example of server-sent events, aka `EventSource`, with actix web.
3 |
4 | ```sh
5 | cargo run
6 | ```
7 |
8 | Open http://localhost:8080/ with a browser, then send events with another HTTP client:
9 |
10 | ```sh
11 | curl localhost:8080/broadcast/my_message
12 | ```
13 |
14 | *my_message* should appear in the browser with a timestamp.
15 |
16 | ## Performance
17 | This implementation serve thousand of clients on a 2013 macbook air without problems.
18 |
19 | Run [benchmark.js](benchmark.js) to benchmark your own system:
20 |
21 | ```sh
22 | $ node benchmark.js
23 | Connected: 1000, connection time: 867 ms, total broadcast time: 23 ms^C⏎
24 | ```
25 |
26 | ### Error *Too many open files*
27 | You may be limited to a maximal number of connections (open file descriptors). Setting maximum number of open file descriptors to 2048:
28 |
29 | ```sh
30 | ulimit -n 2048
31 | ```
32 |
33 | Test maximum number of open connections with [drain.js](drain.js):
34 |
35 | ```sh
36 | $ node drain.js
37 | Connections dropped: 5957, accepting connections: false^C⏎
38 | ```
39 |
40 | _Accepting connections_ indicates wheter resources for the server have been exhausted.
--------------------------------------------------------------------------------
/benchmark.js:
--------------------------------------------------------------------------------
1 | const http = require('http')
2 |
3 | const n = 1000;
4 | let connected = 0;
5 | let messages = 0;
6 | let start = Date.now();
7 | let phase = 'connecting';
8 | let connection_time;
9 | let broadcast_time;
10 |
11 | let message = process.argv[2] || 'msg';
12 | let expected_data = "data: " + message;
13 |
14 | for (let i = 0; i < n; i++) {
15 | http.get({
16 | host: 'localhost',
17 | port: 8080,
18 | path: '/events'
19 | }, response => {
20 | response.on('data', data => {
21 | if (data.includes(expected_data)) {
22 | messages += 1;
23 | } else if (data.includes("data: connected\n")) {
24 | connected += 1;
25 | }
26 | })
27 | }).on('error', (_) => {});
28 | }
29 |
30 | setInterval(() => {
31 | if (phase === 'connecting' && connected === n) {
32 | // done connecting
33 | phase = 'messaging';
34 | connection_time = Date.now() - start;
35 | }
36 |
37 | if (phase === 'messaging') {
38 | phase = 'waiting';
39 | start = Date.now();
40 |
41 | http.get({
42 | host: 'localhost',
43 | port: 8080,
44 | path: '/broadcast/' + message
45 | }, response => {
46 | response.on('data', _ => {})
47 | })
48 | }
49 |
50 | if (phase === 'waiting' && messages >= n) {
51 | // all messages received
52 | broadcast_time = Date.now() - start;
53 | phase = 'paused';
54 | messages = 0;
55 | phase = 'messaging';
56 | }
57 |
58 | process.stdout.write("\r\x1b[K");
59 | process.stdout.write(`Connected: ${connected}, connection time: ${connection_time} ms, total broadcast time: ${broadcast_time} ms`);
60 | }, 20)
61 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use actix_rt::Arbiter;
2 | use actix_web::error::ErrorInternalServerError;
3 | use actix_web::web::{Bytes, Data, Path};
4 | use actix_web::{web, App, Error, HttpResponse, HttpServer, Responder};
5 |
6 | use env_logger;
7 | use tokio::prelude::*;
8 | use tokio::sync::mpsc::{channel, Receiver, Sender};
9 | use tokio::timer::Interval;
10 |
11 | use std::sync::Mutex;
12 | use std::time::{Duration, Instant};
13 |
14 | fn main() {
15 | env_logger::init();
16 | let data = Broadcaster::create();
17 |
18 | HttpServer::new(move || {
19 | App::new()
20 | .register_data(data.clone())
21 | .route("/", web::get().to(index))
22 | .route("/events", web::get().to(new_client))
23 | .route("/broadcast/{msg}", web::get().to(broadcast))
24 | })
25 | .bind("127.0.0.1:8080")
26 | .expect("Unable to bind port")
27 | .run()
28 | .unwrap();
29 | }
30 |
31 | fn index() -> impl Responder {
32 | let content = include_str!("index.html");
33 |
34 | HttpResponse::Ok()
35 | .header("content-type", "text/html")
36 | .body(content)
37 | }
38 |
39 | fn new_client(broadcaster: Data>) -> impl Responder {
40 | let rx = broadcaster.lock().unwrap().new_client();
41 |
42 | HttpResponse::Ok()
43 | .header("content-type", "text/event-stream")
44 | .no_chunking()
45 | .streaming(rx)
46 | }
47 |
48 | fn broadcast(msg: Path, broadcaster: Data>) -> impl Responder {
49 | broadcaster.lock().unwrap().send(&msg.into_inner());
50 |
51 | HttpResponse::Ok().body("msg sent")
52 | }
53 |
54 | struct Broadcaster {
55 | clients: Vec>,
56 | }
57 |
58 | impl Broadcaster {
59 | fn create() -> Data> {
60 | // Data ≃ Arc
61 | let me = Data::new(Mutex::new(Broadcaster::new()));
62 |
63 | // ping clients every 10 seconds to see if they are alive
64 | Broadcaster::spawn_ping(me.clone());
65 |
66 | me
67 | }
68 |
69 | fn new() -> Self {
70 | Broadcaster {
71 | clients: Vec::new(),
72 | }
73 | }
74 |
75 | fn spawn_ping(me: Data>) {
76 | let task = Interval::new(Instant::now(), Duration::from_secs(10))
77 | .for_each(move |_| {
78 | me.lock().unwrap().remove_stale_clients();
79 | Ok(())
80 | })
81 | .map_err(|e| panic!("interval errored; err={:?}", e));
82 |
83 | Arbiter::spawn(task);
84 | }
85 |
86 | fn remove_stale_clients(&mut self) {
87 | let mut ok_clients = Vec::new();
88 | for client in self.clients.iter() {
89 | let result = client.clone().try_send(Bytes::from("data: ping\n\n"));
90 |
91 | if let Ok(()) = result {
92 | ok_clients.push(client.clone());
93 | }
94 | }
95 | self.clients = ok_clients;
96 | }
97 |
98 | fn new_client(&mut self) -> Client {
99 | let (tx, rx) = channel(100);
100 |
101 | tx.clone()
102 | .try_send(Bytes::from("data: connected\n\n"))
103 | .unwrap();
104 |
105 | self.clients.push(tx);
106 | Client(rx)
107 | }
108 |
109 | fn send(&self, msg: &str) {
110 | let msg = Bytes::from(["data: ", msg, "\n\n"].concat());
111 |
112 | for client in self.clients.iter() {
113 | client.clone().try_send(msg.clone()).unwrap_or(());
114 | }
115 | }
116 | }
117 |
118 | // wrap Receiver in own type, with correct error type
119 | struct Client(Receiver);
120 |
121 | impl Stream for Client {
122 | type Item = Bytes;
123 | type Error = Error;
124 |
125 | fn poll(&mut self) -> Poll