Upgrades
A client can ask to switch a connection to another protocol, such as a
WebSocket. A handler takes it by answering
(switch-protocols headers take-over). The server writes
101 Switching Protocols with your headers, then calls
(take-over t retained). From then on the connection is a plain stream of
bytes, and t, the transport, is how you use it.
For WebSockets, use chez-websocket: it does the handshake and the framing on top of this.
;; a client asking for Upgrade: echo gets back every byte it sends
(serve
(lambda (request)
(if (equal? (request-header request "upgrade") "echo")
(switch-protocols
(list (cons (string->utf8 "Upgrade") (string->utf8 "echo")))
(lambda (t retained)
(when (> (bytevector-length retained) 0) (transport-write! t retained))
(transport-read-start! t
(lambda (bv start end) (transport-write! t bv start end))
(lambda (why) (transport-close! t)))
(transport-on-drain! t (lambda () (transport-close! t)))))
(make-response 200 #f '() (string->utf8 "ask for Upgrade: echo\n")))))
Which requests can be taken
- An HTTP/1.1 request with
Connection: upgradeandUpgrade, and no body. A switch answered to any other request gets500, and the server logsinvalid-response not-an-upgrade. - Your
Upgradeheader must name a protocol the client's did, or the client gets500(invalid-upgrade). The server writesConnection: upgradeitself; leave outContent-LengthandTransfer-Encoding. A client that also sentExpect: 100-continuegets100 Continuefirst. retainedis what the client sent after its request that the server had already read. It is usually empty: a WebSocket client waits for the101.
The transport
| Call | Does |
|---|---|
(transport-write! t bv [start end] [done]) | sends the bytes; done gets #f once sent, or the error |
(transport-read-start! t on-bytes on-end) | each read as (on-bytes bv start end); (on-end why) once: eof or the error |
(transport-read-stop! t) | stops reading |
(transport-close! t) | closes, once your writes have gone out and the client closes too |
(transport-deadline! t ms proc) | calls proc once ms milliseconds pass; (transport-deadline! t #f) cancels |
(transport-on-drain! t proc) | calls proc when the server shuts down |
(transport-on-close! t proc) | calls proc once the stream has closed, whoever closed it (at once if it has) |
(transport-abort! t) | closes now: writes not yet sent are dropped, their done getting ECANCELED |
Reading:
- Each read's bytevector is yours to keep. A write of no bytes is refused.
- Nothing is read until you call
transport-read-start!ortransport-close!: what the client sends after the101waits for you, however late you start reading. Once you close, what it sends is read and dropped while the close waits for it to close too.
Writing:
- Nothing bounds what you write but you. A client that stops reading leaves
your writes waiting, in memory. Count what you have written and not yet
had
donefor; past a bound you choose, stop writing, or stop reading what you would echo. To close a client that never catches up, arm a deadline that callstransport-abort!. - Each
transport-write!is a write of its own: the server does not join small ones together, as it does for a response. Many small writes are slower than one, so join small pieces into one bytevector first. - A write after
transport-close!is not sent: itsdonegetsECANCELED. Thatdone, and the one for a write refused as you ask for it, comes on the server's next turn, or, if the server stops first, beforeservereturns. A write you ask for in adonethat comes then gets nodone.
Closing:
transport-close!closes gently, as the server closes its own connections (Connections). It stops handing you reads, but keeps reading and throws away what the client still sends. Once your writes have gone out it tells the client it has finished writing, and it closes when the client closes too, or afterCHEZ_HTTP_LINGER_TIMEOUT(30 s). So a client still sending reads your goodbye instead of a reset. Your close call runs then.- After
transport-close!, the server gives each write still waitingCHEZ_HTTP_WRITE_TIMEOUT(60 s), and aborts the stream past it.transport-read-stop!then changes nothing, and once your writes are out neither does a deadline;transport-abort!still closes at once. A deadline of yours that passes before then still runs. - A client that reads again after an abort may still get part of a write already under way.
Deadlines and shutdown:
- One deadline at a time: a new one replaces the last. It is checked every
CHEZ_HTTP_TICK_MS(250 ms), so it can pass up to that late. - When the server shuts down, your drain call is your chance to say goodbye
(a WebSocket sends close code 1001) and close. The server waits for the
close, and after
CHEZ_HTTP_DRAIN_TIMEOUTcloses the stream itself. The drain call runs once; registered while the server is already shutting down, it runs at once. - A taken stream still counts toward
CHEZ_HTTP_MAX_CONNECTIONSuntil it has closed. One you neither read nor time out stays open, a client gone or not, until the server shuts down: read it, or arm a deadline that aborts it.
Callbacks:
- Your take-over and your callbacks run inside the server's own callbacks,
so keep them quick: they cannot
await. If one raises, the server logstaker-failedand closes the stream.
A taker that waits
To wait, have your take-over answer (awaiting thunk), as a handler does.
The server runs thunk on a coroutine, where it can await, sleep-ms,
with-deadline and resolve (Waiting). Your read callback
still runs in place: it can wake the thunk with the resume an await
handed out.
;; each read is written back by a loop that waits for the next one
(lambda (t retained)
(let ((reads '()) (resume-to #f))
;; in place: keep each read, and wake the loop if it waits
(transport-read-start! t
(lambda (bv start end)
(set! reads (append reads (list (list bv start end))))
(let ((r resume-to)) (set! resume-to #f) (when r (r #t))))
(lambda (why) (transport-close! t)))
;; on a coroutine: wait for a read, write it back, again
(awaiting
(lambda ()
(let loop ()
(when (null? reads) (await (lambda (resume) (set! resume-to resume))))
(let ((read (car reads)))
(set! reads (cdr reads))
(apply transport-write! t read)
(loop)))))))
- When the stream closes, whoever closed it, the wait the thunk is in, or
else its next, raises
&client-closed, once. Let it raise and nothing is logged. Anything else the thunk raises is loggedtaker-failed, and the stream is closed. - What the thunk returns is dropped. The stream stays open until something closes it.
- A thunk that waits holds memory until its wait ends, about 0.7 KB and more the deeper it waits, as a handler that waits does.
Testing a taker
To test your taker without a server, make a transport of your own:
make-transport takes the eight procedures the calls above end up in, in
that order. Seven, without the abort, still make one, but transport-abort!
on it is refused.
To test the handler that switches and its taker together, host the handler
with (http-server testing): what the client sends after the 101 goes to
your taker, and what it writes is what the client reads
(Testing handlers).