Building web apps from scratch - The Simplest Web Server - Part 1
NOTE: This post is still a work in progress!! As we mentioned in the last post, the first layer of our web application is the TCP layer. Here we'll start by listening for new client connections and handle incoming data, which is in the form of a byte stream. Later, we'll use this raw communication layer to create the HTTP request-response abstraction. We will first start with the Linux code, and then adapt it to work on Windows. Also, don't worry if you're missing some details, I'll add the complete program at the end! To handle these connections we need to create a "socket". We use a socket to define which interface we are listening on, and one extra socket per client connection. Sockets are just the way we tell the OS how we want to manage things. First, we start from the listening socket:
1 | // Create the listening socket |
2 | int listen_socket = socket(AF_INET, SOCK_STREAM, 0); |
3 | if (listen_socket == -1) { /* error */ } |
4 |
now, we specify which interface we want to listen on. We do this by filling the struct sockaddr_in
structure and associating it to the socket with the bind
call
1 | struct sockaddr_in bind_buffer; |
2 | bind_buffer.sin_family = AF_INET; |
3 | bind_buffer.sin_port = htons(8080); |
4 | bind_buffer.sin_addr = inet_addr("127.0.0.1"); |
5 | |
6 | if (bind(listen_socket, (struct sockaddr*) &bind_buffer, sizeof(bind_buffer))) { |
7 | /* error */ |
8 | } |
9 |
this tells the system we want to listen on interface 127.0.0.1 and port 8080. The 127.0.0.1 refers to the loopback interface, which is used by processes to talk to other processes. In other words this allows us to test the server without being open to the local network. When we put the server online, this will need to change. The port used by HTTP is 80, and the one used by HTTPS is 443. We'll have to change that too later. Now we tell the system to actually use the socket and listen for connections:
1 | if (listen(listen_socket, 32)) { /* error */ } |
2 |
the second argument is the size of the backlog. When connection requests first arrive to the system, they are inserted in a queue. Our process will then use the accept
function to get a connection from that queue. The backlog parameters tells the system how long we want that queue. This parameter is important because when the queue is full other incoming connections will be rejected. But in general this isn't much of a problem since the server easily keeps up.
Now comes the main loop of the server, where the great majority of its time will be spent. Each iteration will accept a connection, receive a request, then send a response. Lets see:
1 | while (1) { |
2 | int client_socket = accept(listen_socket, NULL, NULL); |
3 | if (client_socket == -1) { /* error */ } |
4 | |
5 | char request_buffer[4096]; |
6 | int len = recv(client_socket, request_buffer, sizeof(request_buffer), 0); |
7 | if (len < 0) { /* error */ } |
8 | |
9 | // We ignore the request contents for now and always |
10 | // respond with the same message |
11 | char response_buffer[] = |
12 | "HTTP/1.0 200 OK\r\n" |
13 | "Content-Length: 13\r\n" |
14 | "Content-Type: text/plain\r\n" |
15 | "\r\n" |
16 | "Hello, world!"; |
17 | send(client_socket, response_buffer, sizeof(response_buffer), 0); |
18 | |
19 | close(client_socket); |
20 | } |
21 |
Perfect! This is extremely bare-bones but should work. Here is the complete version of the program:
1 | #include <stdio.h> // printf |
2 | #include <unistd.h> // close |
3 | #include <arpa/inet.h> // socket, htons, inet_addr, sockaddr_in, bind, listen, accept, recv, send |
4 | |
5 | int main() |
6 | { |
7 | // Create the listening socket |
8 | int listen_socket = socket(AF_INET, SOCK_STREAM, 0); |
9 | if (listen_socket == -1) { |
10 | printf("socket failed\n"); |
11 | return -1; |
12 | } |
13 | |
14 | struct sockaddr_in bind_buffer; |
15 | bind_buffer.sin_family = AF_INET; |
16 | bind_buffer.sin_port = htons(8080); |
17 | bind_buffer.sin_addr = inet_addr("127.0.0.1"); |
18 | |
19 | if (bind(listen_socket, (struct sockaddr*) &bind_buffer, sizeof(bind_buffer))) { |
20 | printf("bind failed\n"); |
21 | return -1; |
22 | } |
23 | |
24 | if (listen(listen_socket, 32)) { |
25 | printf("listen failed\n"); |
26 | return -1; |
27 | } |
28 | |
29 | while (1) { |
30 | int client_socket = accept(listen_socket, NULL, NULL); |
31 | if (client_socket == -1) { |
32 | printf("accept failed\n"); |
33 | continue; |
34 | } |
35 | |
36 | char request_buffer[4096]; |
37 | int len = recv(client_socket, request_buffer, sizeof(request_buffer), 0); |
38 | if (len < 0) { |
39 | printf("recv failed\n"); |
40 | close(client_socket); |
41 | continue; |
42 | } |
43 | |
44 | // We ignore the request contents for now and always |
45 | // respond with the same message |
46 | char response_buffer[] = |
47 | "HTTP/1.0 200 OK\r\n" |
48 | "Content-Length: 13\r\n" |
49 | "Content-Type: text/plain\r\n" |
50 | "\r\n" |
51 | "Hello, world!"; |
52 | send(client_socket, response_buffer, sizeof(response_buffer), 0); |
53 | |
54 | close(client_socket); |
55 | } |
56 | // This point will never be reached |
57 | } |
58 |
For the windows version, there are a number of minor differences:
- We must initialize the socket subsystem by calling
WSAStartup
- The socket type is
SOCKET
instead ofint
- The invalid value for a socket is
INVALID_SOCKET
instead of-1
- Instead of
close
we useclosesocket
- The headers to include
To make a cross-platform version, we can leverage the compiler's preprocessor. If the _WIN32
symbol is defined, we know we are on windows. You can find the full code here.
it’s a bit ugly, but fortunately we won’t need to do this often. Save this program to a http_server.c
file and open a terminal in the same directory, then compile the program by running this on Linux:
gcc http_server.c -o http_server.out
and this on windows:
gcc http_server.c -o http_server.out -lws2_32
Now open a browser and visit http://127.0.0.1:8080/. You should be a page saying "Hello, world!".