Skip to content

Commit cfd19bf

Browse files
authored
x.vweb.sse: reimplement SSE module for x.vweb (#20203)
1 parent 25c900f commit cfd19bf

File tree

4 files changed

+209
-2
lines changed

4 files changed

+209
-2
lines changed

vlib/x/vweb/context.v

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,16 @@ pub fn (mut ctx Context) send_response_to_client(mimetype string, response strin
9797
// set Content-Type and Content-Length headers
9898
mut custom_mimetype := if ctx.content_type.len == 0 { mimetype } else { ctx.content_type }
9999
ctx.res.header.set(.content_type, custom_mimetype)
100-
ctx.res.header.set(.content_length, ctx.res.body.len.str())
100+
if ctx.res.body.len > 0 {
101+
ctx.res.header.set(.content_length, ctx.res.body.len.str())
102+
}
101103
// send vweb's closing headers
102104
ctx.res.header.set(.server, 'VWeb')
103-
ctx.res.header.set(.connection, 'close')
105+
// sent `Connection: close header` by default, if the user hasn't specified that the
106+
// connection should not be closed.
107+
if !ctx.takeover {
108+
ctx.res.header.set(.connection, 'close')
109+
}
104110
// set the http version
105111
ctx.res.set_version(.v1_1)
106112
if ctx.res.status_code == 0 {

vlib/x/vweb/sse/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Server Sent Events
2+
3+
This module implements the server side of `Server Sent Events`, SSE.
4+
See [mozilla SSE][mozilla_sse]
5+
as well as [whatwg][whatwg html spec]
6+
for detailed description of the protocol, and a simple web browser client example.
7+
8+
## Usage
9+
10+
With SSE we want to keep the connection open, so we are able to
11+
keep sending events to the client. But if we hold the connection open indefinitely
12+
vweb isn't able to process any other requests.
13+
14+
We can let vweb know that it can continue
15+
processing other requests and that we will handle the connection ourself by
16+
returning `ctx.takeover_conn()`. Vweb will not close the connection and we can handle
17+
the connection in a seperate thread.
18+
19+
**Example:**
20+
```v ignore
21+
import x.vweb.sse
22+
23+
// endpoint handler for SSE connections
24+
fn (app &App) sse(mut ctx Context) vweb.Result {
25+
// handle the connection in a new thread
26+
spawn handle_sse_conn(mut ctx)
27+
// let vweb know that the connection should not be closed
28+
return ctx.takeover_conn()
29+
}
30+
31+
fn handle_sse_conn(mut ctx Context) {
32+
// pass vweb.Context
33+
mut sse_conn := sse.start_connection(mut ctx.Context)
34+
35+
// send a message every second 3 times
36+
for _ in 0.. 3 {
37+
time.sleep(time.second)
38+
sse_conn.send_message(data: 'ping') or { break }
39+
}
40+
// close the SSE connection
41+
sse_conn.close()
42+
}
43+
```
44+
45+
Javascript code:
46+
```js
47+
const eventSource = new EventSource('/sse');
48+
49+
eventSource.addEventListener('message', (event) => {
50+
console.log('received mesage:', event.data);
51+
});
52+
53+
eventSource.addEventListener('close', () => {
54+
console.log('closing the connection')
55+
// prevent browser from reconnecting
56+
eventSource.close();
57+
});
58+
```
59+
60+
[mozilla_sse]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
61+
[whatwg html spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events

vlib/x/vweb/sse/sse.v

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
module sse
2+
3+
import x.vweb
4+
import net
5+
import strings
6+
7+
// This module implements the server side of `Server Sent Events`.
8+
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
9+
// as well as https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
10+
// for detailed description of the protocol, and a simple web browser client example.
11+
//
12+
// > Event stream format
13+
// > The event stream is a simple stream of text data which must be encoded using UTF-8.
14+
// > Messages in the event stream are separated by a pair of newline characters.
15+
// > A colon as the first character of a line is in essence a comment, and is ignored.
16+
// > Note: The comment line can be used to prevent connections from timing out;
17+
// > a server can send a comment periodically to keep the connection alive.
18+
// >
19+
// > Each message consists of one or more lines of text listing the fields for that message.
20+
// > Each field is represented by the field name, followed by a colon, followed by the text
21+
// > data for that field's value.
22+
23+
@[params]
24+
pub struct SSEMessage {
25+
pub mut:
26+
id string
27+
event string
28+
data string
29+
retry int
30+
}
31+
32+
@[heap]
33+
pub struct SSEConnection {
34+
pub mut:
35+
conn &net.TcpConn @[required]
36+
}
37+
38+
// start an SSE connection
39+
pub fn start_connection(mut ctx vweb.Context) &SSEConnection {
40+
ctx.takeover_conn()
41+
ctx.res.header.set(.connection, 'keep-alive')
42+
ctx.res.header.set(.cache_control, 'no-cache')
43+
ctx.send_response_to_client('text/event-stream', '')
44+
45+
return &SSEConnection{
46+
conn: ctx.conn
47+
}
48+
}
49+
50+
// send_message sends a single message to the http client that listens for SSE.
51+
// It does not close the connection, so you can use it many times in a loop.
52+
pub fn (mut sse SSEConnection) send_message(message SSEMessage) ! {
53+
mut sb := strings.new_builder(512)
54+
if message.id != '' {
55+
sb.write_string('id: ${message.id}\n')
56+
}
57+
if message.event != '' {
58+
sb.write_string('event: ${message.event}\n')
59+
}
60+
if message.data != '' {
61+
sb.write_string('data: ${message.data}\n')
62+
}
63+
if message.retry != 0 {
64+
sb.write_string('retry: ${message.retry}\n')
65+
}
66+
sb.write_string('\n')
67+
sse.conn.write(sb)!
68+
}
69+
70+
// send a 'close' event and close the tcp connection.
71+
pub fn (mut sse SSEConnection) close() {
72+
sse.send_message(event: 'close', data: 'Closing the connection', retry: -1) or {}
73+
sse.conn.close() or {}
74+
}

vlib/x/vweb/sse/sse_test.v

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import x.vweb
2+
import x.vweb.sse
3+
import time
4+
import net.http
5+
6+
const port = 13008
7+
const localserver = 'http://127.0.0.1:${port}'
8+
const exit_after = time.second * 10
9+
10+
pub struct Context {
11+
vweb.Context
12+
}
13+
14+
pub struct App {}
15+
16+
fn (app &App) sse(mut ctx Context) vweb.Result {
17+
spawn handle_sse_conn(mut ctx)
18+
return ctx.takeover_conn()
19+
}
20+
21+
fn handle_sse_conn(mut ctx Context) {
22+
// pass vweb.Context
23+
mut sse_conn := sse.start_connection(mut ctx.Context)
24+
25+
for _ in 0 .. 3 {
26+
time.sleep(time.second)
27+
sse_conn.send_message(data: 'ping') or { break }
28+
}
29+
sse_conn.close()
30+
}
31+
32+
fn testsuite_begin() {
33+
mut app := &App{}
34+
35+
spawn vweb.run_at[App, Context](mut app, port: port, family: .ip)
36+
// app startup time
37+
time.sleep(time.second * 2)
38+
spawn fn () {
39+
time.sleep(exit_after)
40+
assert true == false, 'timeout reached!'
41+
exit(1)
42+
}()
43+
}
44+
45+
fn test_sse() ! {
46+
mut x := http.get('${localserver}/sse')!
47+
48+
connection := x.header.get(.connection) or {
49+
assert true == false, 'Header Connection should be set!'
50+
panic('missing header')
51+
}
52+
cache_control := x.header.get(.cache_control) or {
53+
assert true == false, 'Header Cache-Control should be set!'
54+
panic('missing header')
55+
}
56+
content_type := x.header.get(.content_type) or {
57+
assert true == false, 'Header Content-Type should be set!'
58+
panic('missing header')
59+
}
60+
assert connection == 'keep-alive'
61+
assert cache_control == 'no-cache'
62+
assert content_type == 'text/event-stream'
63+
64+
eprintln(x.body)
65+
assert x.body == 'data: ping\n\ndata: ping\n\ndata: ping\n\nevent: close\ndata: Closing the connection\nretry: -1\n\n'
66+
}

0 commit comments

Comments
 (0)