Building a Single-Threaded Web Server
Before diving into concurrency, let's build a basic web server that handles one request at a time. This gives us a solid foundation to build upon.
Understanding TCP and HTTP
A web server operates at two levels:
- TCP (Transmission Control Protocol) - The low-level networking protocol that handles connection establishment and data transmission
- HTTP (HyperText Transfer Protocol) - The application-level protocol that defines the format of requests and responses
Our server will:
- Listen on a TCP socket
- Accept incoming connections
- Read HTTP requests
- Send back HTTP responses
Creating the Server
Let's start with the main structure. Update src/main.ox:
import std.io.{BufRead, BufReader, Write}
import std.net.TcpListener
import std.fs.readToString
fn main() {
let listener = TcpListener.bind("127.0.0.1:7878").expect("Failed to bind to port 7878")
println!("Server listening on http://127.0.0.1:7878")
for stream in listener.incoming() {
var stream = stream.expect("Failed to accept connection")
handleConnection(&mut stream)
}
}
fn handleConnection(stream: &mut TcpStream) {
let bufReader = BufReader.new(stream)
let requestLine = bufReader.lines().next().expect("Should have first line")
.expect("Should read first line")
let (status, filename) = if requestLine == "GET / HTTP/1.1" {
("200 OK", "hello.html")
} else {
("404 NOT FOUND", "404.html")
}
let contents = readToString(filename).unwrapOrElse { _ -> "Error reading file".toString() }
let length = contents.len()
let response = "HTTP/1.1 \(status)\r\nContent-Length: \(length)\r\n\r\n\(contents)"
stream.writeAll(response.asBytes()).expect("Failed to write response")
}
Imports and Modules
Let's understand the imports:
std.io.{BufRead, BufReader, Write}- For reading buffered input and writing outputstd.net.TcpListener- For listening on a TCP socketstd.fs.readToString- For reading file contents
Notice the Oxide syntax for method paths using . instead of Rust's ::
Listening for Connections
let listener = TcpListener.bind("127.0.0.1:7878").expect("Failed to bind to port 7878")
The TcpListener.bind method:
- Takes an address and port as a string
- Returns a
Resultthat we unwrap withexpect - The address
127.0.0.1is localhost (your own machine) - Port 7878 is somewhat arbitrary - choose any unused port
Iterating Over Connections
for stream in listener.incoming() {
var stream = stream.expect("Failed to accept connection")
handleConnection(&mut stream)
}
The listener.incoming() method returns an iterator of incoming TCP connections. Each item is a Result<TcpStream, Error>. We:
- Use
expectto handle the error - Make the stream mutable with
varsince we'll write to it - Pass it to our handler function
Parsing HTTP Requests
let bufReader = BufReader.new(stream)
let requestLine = bufReader.lines().next().expect("Should have first line")
.expect("Should read first line")
A typical HTTP request looks like:
GET / HTTP/1.1
Host: 127.0.0.1:7878
User-Agent: curl/7.64.0
Accept: */*
We read just the first line (the request line) which contains:
- The HTTP method (GET, POST, etc.)
- The path being requested
- The HTTP version
Routing Requests
let (status, filename) = if requestLine == "GET / HTTP/1.1" {
("200 OK", "hello.html")
} else {
("404 NOT FOUND", "404.html")
}
This is simple routing:
- If the request is for
/, we servehello.htmlwith a 200 OK status - For anything else, we serve
404.htmlwith a 404 NOT FOUND status
Building the Response
let contents = readToString(filename).unwrapOrElse { _ -> "Error reading file".toString() }
let length = contents.len()
let response = "HTTP/1.1 \(status)\r\nContent-Length: \(length)\r\n\r\n\(contents)"
stream.writeAll(response.asBytes()).expect("Failed to write response")
An HTTP response has the format:
HTTP/1.1 200 OK
Content-Length: 44
<html body content>
We:
- Read the HTML file contents
- Calculate the content length
- Build the response string with proper HTTP headers
- Write the bytes to the stream
HTML Files
Create hello.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from our Oxide web server</p>
</body>
</html>
Create 404.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
Running the Server
Compile and run:
cargo run
Then visit http://127.0.0.1:7878 in your browser. You should see "Hello!" displayed.
Try visiting http://127.0.0.1:7878/something-else to see the 404 page.
Testing the Server
You can also test with curl:
curl http://127.0.0.1:7878
curl http://127.0.0.1:7878/not-found
The Problem with This Approach
Our current server handles one request at a time. If a client connects and takes a long time to send data, no other clients can connect until that request is fully processed.
Try this to see the problem:
# In one terminal:
cargo run
# In another terminal:
curl http://127.0.0.1:7878/sleep
If sleep.html takes 5 seconds to process, other requests will block during that time.
This is where threading comes in. In the next section, we'll implement a thread pool to handle multiple requests concurrently.
Summary
We've built a basic working web server that:
- Listens on a TCP socket
- Reads HTTP requests
- Parses the request path
- Sends back HTML responses
The single-threaded design is simple but limited. Let's make it concurrent in the next section.