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:

  1. TCP (Transmission Control Protocol) - The low-level networking protocol that handles connection establishment and data transmission
  2. HTTP (HyperText Transfer Protocol) - The application-level protocol that defines the format of requests and responses

Our server will:

  1. Listen on a TCP socket
  2. Accept incoming connections
  3. Read HTTP requests
  4. 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 output
  • std.net.TcpListener - For listening on a TCP socket
  • std.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 Result that we unwrap with expect
  • The address 127.0.0.1 is 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 expect to handle the error
  • Make the stream mutable with var since 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 serve hello.html with a 200 OK status
  • For anything else, we serve 404.html with 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.