TT Lab
Get started
Learn Learning paths Courses

Real-Time Communication — WebSocket, gRPC Streaming and WebRTC

Build a WebSocket server from the bytes up

Continue in TT Lab

Goal

Implement the RFC 6455 handshake, frames, masking, control frames, and close codes with only the standard library, and connect a real library client to confirm that you follow the rules.

Why it matters

WebSocket failure records usually leave just one line, the close code. If you don't know what 1002, 1006, and 1009 mean, and who broke which rule to produce each code, you cannot read that line. Problems where the connection drops only behind a proxy or only on large messages are also, in the end, problems of these bytes. If you do once by hand what a library did for you, from then on the library's error messages become readable.

Steps

  1. Compute the handshake key — In /root/rt/ws/wsproto.py, create accept_key(key). Append 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 to the Sec-WebSocket-Key string the client sent, hash it with SHA-1, and return the string of those 20 bytes encoded in base64.
  2. Build a frame — In /root/rt/ws/wsproto.py, add encode_frame(opcode, payload, fin=True, mask_key=None). The first byte is the FIN bit and the opcode, and the second byte is the MASK bit and the length. If the length is 125 or less, write it as is, if 65535 or less, write 126 followed by 2 bytes, and if larger, write 127 followed by 8 bytes, in big-endian. If 4 bytes come in as mask_key, set the MASK bit, write those 4 bytes after the length, and then append the payload with its i-th byte XORed with mask_key[i % 4].
  3. Read a frame and pick out rule violations — In /root/rt/ws/wsproto.py, add the ProtocolError(code) exception and decode_frame(buf). If one frame has not fully arrived in buf yet, return None, and if it has, return (fin bool, opcode int, payload bytes with the mask removed, masked bool, number of bytes used). If an RSV bit is set, the opcode is undefined (3 to 7 or 11 to 15), or a control frame (8, 9, 10) has a payload over 125 bytes or FIN off, raise ProtocolError(1002). ProtocolError must carry the close code as its code attribute.
  4. Turn an HTTP request into a 101 — In /root/rt/ws/wsproto.py, add handshake_response(request). request is the HTTP request bytes up to the blank line. If it is a GET, Upgrade is websocket, Connection has the Upgrade token, Sec-WebSocket-Version is 13, and there is a Sec-WebSocket-Key, return the response bytes containing "HTTP/1.1 101 Switching Protocols" and the Upgrade, Connection, and Sec-WebSocket-Accept headers. If only the version differs, return 426 with a Sec-WebSocket-Version: 13 header, and for any other defect, return 400. Header names and the websocket and upgrade values are case-insensitive.
  5. Stand up a server and echo back — In /root/rt/ws/wsproto.py, add serve(host, port, max_size=1048576). Do the handshake with one thread per connection, and if it is not a 101, send the response and then close. After that, read frames and send text and binary messages back with the same opcode. Collect a fragmented message (FIN 0 and continuation frames 0) and send it back as one, and answer a ping inserted between fragments immediately with a pong with the same payload. Frames the server sends are not masked.
  6. Close a peer that broke the rules with the proper code — Fix serve to handle closing. If the peer's close frame has a code, send back a close with the same code, and if not, with an empty payload, and then close TCP. For an unmasked client frame, send a close with 1002 and close; for a non-UTF-8 text message, 1007; and if the collected message exceeds max_size bytes, 1009. If decode_frame raises ProtocolError, close with that code.
  7. Connect with a real client — There is nothing more to build. The grader connects to your server with a client from the websockets library, sends Korean text, a 70000-byte binary (a size that uses the 8-byte length), and a ping, and closes with 1000. You pass if the library does not cut the connection for a protocol violation and receives all the echoes, the pong, and close code 1000.

Notes

Compute the handshake key

In /root/rt/ws/wsproto.py, create accept_key(key). Append 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 to the Sec-WebSocket-Key string the client sent, hash it with SHA-1, and return the string of those 20 bytes encoded in base64.

The example key dGhlIHNhbXBsZSBub25jZQ== in RFC 6455 section 1.3 must become s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. The key point is to append the key as a string as it is, without decoding it from base64.

Build a frame

In /root/rt/ws/wsproto.py, add encode_frame(opcode, payload, fin=True, mask_key=None). The first byte is the FIN bit and the opcode, and the second byte is the MASK bit and the length. If the length is 125 or less, write it as is, if 65535 or less, write 126 followed by 2 bytes, and if larger, write 127 followed by 8 bytes, in big-endian. If 4 bytes come in as mask_key, set the MASK bit, write those 4 bytes after the length, and then append the payload with its i-th byte XORed with mask_key[i % 4].

Masking is not encryption. The key travels in the frame as it is. The purpose is to prevent the attack that makes an intermediary cache mistake WebSocket bytes for an HTTP response (RFC 6455 section 10.3).

Read a frame and pick out rule violations

In /root/rt/ws/wsproto.py, add the ProtocolError(code) exception and decode_frame(buf). If one frame has not fully arrived in buf yet, return None, and if it has, return (fin bool, opcode int, payload bytes with the mask removed, masked bool, number of bytes used). If an RSV bit is set, the opcode is undefined (3 to 7 or 11 to 15), or a control frame (8, 9, 10) has a payload over 125 bytes or FIN off, raise ProtocolError(1002). ProtocolError must carry the close code as its code attribute.

You read from a stream, so there is no guarantee that a whole frame arrives at once. In the order of the 2-byte header, the extended length, the masking key, and then the payload, it is None if the bytes fall short at any of those points. You have to return the number of bytes used so that the caller can go on reading the next frame.

Turn an HTTP request into a 101

In /root/rt/ws/wsproto.py, add handshake_response(request). request is the HTTP request bytes up to the blank line. If it is a GET, Upgrade is websocket, Connection has the Upgrade token, Sec-WebSocket-Version is 13, and there is a Sec-WebSocket-Key, return the response bytes containing "HTTP/1.1 101 Switching Protocols" and the Upgrade, Connection, and Sec-WebSocket-Accept headers. If only the version differs, return 426 with a Sec-WebSocket-Version: 13 header, and for any other defect, return 400. Header names and the websocket and upgrade values are case-insensitive.

The Connection header can have several tokens, like "keep-alive, Upgrade". Split by commas and look at them one by one. If you write the supported version in the 426, the client knows what to try again with.

Stand up a server and echo back

In /root/rt/ws/wsproto.py, add serve(host, port, max_size=1048576). Do the handshake with one thread per connection, and if it is not a 101, send the response and then close. After that, read frames and send text and binary messages back with the same opcode. Collect a fragmented message (FIN 0 and continuation frames 0) and send it back as one, and answer a ping inserted between fragments immediately with a pong with the same payload. Frames the server sends are not masked.

Control frames can be inserted in the middle of a fragmented message (RFC 6455 section 5.4). Keep the buffer that collects fragments separate from the control frame handling. If the server masks, a client that follows the rules has to cut the connection.

Close a peer that broke the rules with the proper code

Fix serve to handle closing. If the peer's close frame has a code, send back a close with the same code, and if not, with an empty payload, and then close TCP. For an unmasked client frame, send a close with 1002 and close; for a non-UTF-8 text message, 1007; and if the collected message exceeds max_size bytes, 1009. If decode_frame raises ProtocolError, close with that code.

The close code is the only diagnosis you leave for the other side. 1006 is not a code you send; it is a name the receiving side applies meaning "dropped without a close frame." If you have a reason to close, always send the close frame first.

Connect with a real client

There is nothing more to build. The grader connects to your server with a client from the websockets library, sends Korean text, a 70000-byte binary (a size that uses the 8-byte length), and a ping, and closes with 1000. You pass if the library does not cut the connection for a protocol violation and receives all the echoes, the pong, and close code 1000.

A hand-built implementation tends to pass only hand-built tests. The library strictly checks the server frames' MASK bit, the minimal encoding of the length field, and the closing order. If it fails, the message gives the reason the library left.