Skip to content

Commit fcf598b

Browse files
fix: handle base64 encoding correctly
1 parent a89924f commit fcf598b

File tree

5 files changed

+124
-29
lines changed

5 files changed

+124
-29
lines changed

examples/websocket_client/main.mbt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@
1717
///
1818
/// This demonstrates how to connect to a WebSocket server,
1919
/// send messages, and receive responses.
20-
fn init {
21-
println("WebSocket client example")
20+
async fn main {
21+
connect_to_echo_server()
2222
}
2323

2424
///|
2525
pub async fn connect_to_echo_server() -> Unit {
2626
println("Connecting to WebSocket echo server at localhost:8080")
2727

2828
// Connect to the server
29-
let client = @websocket.Client::connect("localhost", "/ws", port=8080)
29+
let client = @websocket.Client::connect("0.0.0.0", "/", port=8080)
3030
println("Connected successfully!")
3131

3232
// Send some test messages
@@ -65,6 +65,6 @@ pub async fn connect_to_echo_server() -> Unit {
6565

6666
// Close the connection
6767
println("Closing connection...")
68-
client.close()
68+
client.send_close()
6969
println("Client example completed")
7070
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{
22
"import": [
3-
"moonbitlang/async/websocket"
4-
]
3+
"moonbitlang/async/websocket",
4+
"moonbitlang/async"
5+
],
6+
"is-main": true
57
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_a_WebSocket_server_in_JavaScript_Deno
2+
Deno.serve({
3+
port: 8080,
4+
handler(request) {
5+
if (request.headers.get("upgrade") !== "websocket") {
6+
return new Response(null, { status: 200 });
7+
}
8+
const { socket, response } = Deno.upgradeWebSocket(request);
9+
socket.onopen = () => {
10+
console.log("CONNECTED");
11+
};
12+
socket.onmessage = (event) => {
13+
console.log("MESSAGE RECEIVED: ", event.data);
14+
socket.send("pong");
15+
};
16+
socket.onclose = () => console.log("DISCONNECTED");
17+
socket.onerror = (err) => console.error("ERROR: ", err);
18+
return response;
19+
},
20+
});

src/websocket/client.mbt

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ struct Client {
3232
/// ```moonbit no-check
3333
/// let ws = Client::connect("example.com", "/ws")
3434
/// ```
35+
///
3536
pub async fn Client::connect(
3637
host : String,
3738
path : String,
3839
port? : Int = 80,
3940
headers? : Map[String, String] = {},
4041
) -> Client {
42+
// Ref : https://datatracker.ietf.org/doc/html/rfc6455#section-4.1
4143
let seed = FixedArray::make(32, b'\x00')
4244
if @tls.rand_bytes(seed, 32) != 1 {
4345
fail("Failed to get random bytes for WebSocket client")
@@ -73,21 +75,61 @@ pub async fn Client::connect(
7375
conn.write("\r\n")
7476
7577
// Read and validate handshake response
76-
let response_line = conn.read_exactly(1024) // Read initial response
77-
let response_str = @encoding/utf8.decode(response_line)
78-
guard response_str.contains("101") &&
79-
response_str.contains("Switching Protocols") else {
78+
let reader = @io.BufferedReader::new(conn)
79+
guard reader.read_line() is Some(response_line) else {
80+
conn.close()
81+
raise InvalidHandshake("Server closed connection during handshake")
82+
}
83+
guard response_line.contains("101") &&
84+
response_line.contains("Switching Protocols") else {
8085
conn.close()
8186
raise InvalidHandshake(
82-
"Server did not respond with 101 Switching Protocols",
87+
"Server did not respond with 101 Switching Protocols: \{response_line}",
8388
)
8489
}
90+
let headers : Map[String, String] = {}
91+
while reader.read_line() is Some(line) {
92+
if line.is_blank() {
93+
break
94+
}
95+
let line = line[:-1] // Remove trailing \r
8596
86-
// Basic validation that the response looks like a proper WebSocket upgrade
87-
guard response_str.contains("websocket") else {
88-
conn.close()
97+
// Parse header line
98+
if line.contains(":") {
99+
let parts = line.split(":").to_array()
100+
if parts.length() >= 2 {
101+
let key = parts[0].trim(chars=" \t").to_string().to_lower()
102+
// Join remaining parts in case the value contains colons
103+
let value_parts = parts[1:]
104+
let value = if value_parts.length() == 1 {
105+
value_parts[0].trim(chars=" \t").to_string()
106+
} else {
107+
value_parts.join(":").trim(chars=" \t").to_string()
108+
}
109+
// Handle multi-value headers by taking the first value
110+
if not(headers.contains(key)) {
111+
headers[key] = value
112+
}
113+
}
114+
}
115+
}
116+
117+
// Validate WebSocket handshake headers
118+
guard headers.get("upgrade") is Some(upgrade) &&
119+
upgrade.to_lower() == "websocket" else {
120+
raise InvalidHandshake("Missing or invalid Upgrade header")
121+
}
122+
guard headers.get("connection") is Some(connection) &&
123+
connection.to_lower().contains("upgrade") else {
124+
raise InvalidHandshake("Missing or invalid Connection header")
125+
}
126+
guard headers.get("sec-websocket-accept") is Some(accept_key) else {
127+
raise InvalidHandshake("Missing Sec-WebSocket-Accept header")
128+
}
129+
let expected_accept_key = generate_accept_key(key)
130+
guard accept_key.trim(chars=" \t\r") == expected_accept_key else {
89131
raise InvalidHandshake(
90-
"Server response does not contain websocket upgrade confirmation",
132+
"Invalid Sec-WebSocket-Accept value: \{accept_key} != \{expected_accept_key}",
91133
)
92134
}
93135
{ conn, closed: None, rand }
@@ -102,6 +144,30 @@ pub fn Client::close(self : Client) -> Unit {
102144
}
103145
}
104146
147+
///|
148+
pub async fn Client::send_close(
149+
self : Client,
150+
code? : CloseCode = Normal,
151+
reason? : BytesView = "",
152+
) -> Unit {
153+
if self.closed is Some(_) {
154+
return
155+
}
156+
let mut payload = FixedArray::make(0, b'\x00')
157+
let code_int = code.to_int()
158+
payload = FixedArray::make(2 + reason.length(), b'\x00')
159+
payload.unsafe_write_uint16_be(0, code_int.to_uint16())
160+
payload.blit_from_bytesview(2, reason)
161+
write_frame(
162+
self.conn,
163+
true,
164+
OpCode::Close,
165+
payload.unsafe_reinterpret_as_bytes(),
166+
self.rand.int().to_be_bytes(),
167+
)
168+
self.closed = Some(code)
169+
}
170+
105171
///|
106172
/// Send a text message
107173
pub async fn Client::send_text(self : Client, text : String) -> Unit {
@@ -190,15 +256,6 @@ pub async fn Client::receive(self : Client) -> Message {
190256
let code_int = (payload_arr[0].to_int() << 8) |
191257
payload_arr[1].to_int()
192258
close_code = CloseCode::from_int(code_int).unwrap_or(Normal)
193-
// Reason is parsed but not used in client close handling
194-
// let mut reason = ""
195-
// if frame.payload.length() > 2 {
196-
// let reason_bytes = FixedArray::make(frame.payload.length() - 2, b'\x00')
197-
// for i = 2; i < frame.payload.length(); i = i + 1 {
198-
// reason_bytes[i - 2] = payload_arr[i]
199-
// }
200-
// reason = @encoding/utf8.decode_lossy(reason_bytes.unsafe_reinterpret_as_bytes())
201-
// }
202259
}
203260
self.closed = Some(close_code)
204261
raise ConnectionClosed(close_code)

src/websocket/utils.mbt

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ fn mask_payload(data : FixedArray[Byte], mask : Bytes) -> Unit {
2424
/// Base64 encoding
2525
fn base64_encode(data : Bytes) -> String {
2626
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
27-
let data_arr = data.to_fixedarray()
27+
let data_arr = data
2828
let mut result = ""
2929
for i = 0; i < data_arr.length(); i = i + 3 {
3030
let b1 = data_arr[i].to_int()
@@ -39,22 +39,38 @@ fn base64_encode(data : Bytes) -> String {
3939
0
4040
}
4141
let combined = (b1 << 16) | (b2 << 8) | b3
42-
result = result + chars[(combined >> 18) & 0x3F].to_string()
43-
result = result + chars[(combined >> 12) & 0x3F].to_string()
42+
result = result +
43+
chars[(combined >> 18) & 0x3F].unsafe_to_char().to_string()
44+
result = result +
45+
chars[(combined >> 12) & 0x3F].unsafe_to_char().to_string()
4446
if i + 1 < data_arr.length() {
45-
result = result + chars[(combined >> 6) & 0x3F].to_string()
47+
result = result +
48+
chars[(combined >> 6) & 0x3F].unsafe_to_char().to_string()
4649
} else {
4750
result = result + "="
4851
}
4952
if i + 2 < data_arr.length() {
50-
result = result + chars[combined & 0x3F].to_string()
53+
result = result + chars[combined & 0x3F].unsafe_to_char().to_string()
5154
} else {
5255
result = result + "="
5356
}
5457
}
5558
result
5659
}
5760

61+
///|
62+
test "base64 encode" {
63+
inspect(base64_encode(b"light w"), content="bGlnaHQgdw==")
64+
inspect(base64_encode(b"light wo"), content="bGlnaHQgd28=")
65+
inspect(base64_encode(b"light wor"), content="bGlnaHQgd29y")
66+
inspect(base64_encode(b"light work"), content="bGlnaHQgd29yaw==")
67+
inspect(base64_encode(b"light work."), content="bGlnaHQgd29yay4=")
68+
inspect(
69+
base64_encode(b"a Ā 𐀀 文 🦄"),
70+
content="YSDEgCDwkICAIOaWhyDwn6aE",
71+
)
72+
}
73+
5874
///|
5975
const MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
6076

0 commit comments

Comments
 (0)