Writing a NodeJS HTTP Server Without Frameworks

Sarah Cross
Jun 12, 2020 · 11 min read
NodeJS

I’ve ended up working a lot with REST APIs over the past few days, from a startup project with my friend to integrations with MySQL databases.

Ultimately, there’s not a lot out there about actually using NodeJS on its own for HTTP servers, with its own built in libraries like HTTP. These libraries really can set up a server, albeit in a little more complicated manner than frameworks such as Express.

I have to admit, I lean more towards the using-as-few-libraries-as-possible spectrum. But even for those that don’t, using more low-level/built-in libraries really helps in understanding the actual processes working in the background, rather than dismissing it as magic and something another library has to deal with.

So with that quick introduction, let’s get started!

First off, you should have NodeJS installed, which you can do here: https://nodejs.org/en/download/.

Having Postman is really important as well for developing REST APIs in general, as it’s a complete API development environment: https://www.postman.com/downloads/.

If you have the following, you should be good. Just make sure to check out that NodeJS is installed correctly, which you can do by running the following:

node --version

That line should pull up a version number if Node’s installed correctly.

Now, on to the HTTP server. Open up a JS file and let’s get started!

First, start by requiring the HTTP module. This is a built-in module in NodeJS which creates the framework for all of our HTTP server’s code.

const http = require("http")

Then we’re going to create the server using createServer, which contains the parameters request and response. Request contains an object of information concerning the user’s request(method, path, etc), and response is what we write to in order to send our response.

http.createServer((req, res) => { // Creating the server
// req is the user's request, and res is our response.
// We write to the response by using:
res.write("Hello there!") // Writes our response
res.end() // Closes the response, telling the user that we're done writing to the response and our response can be sent over.
})
An HTTP request and response

There are also headers, which we use to tell the browser how their request was processed(status code) and what kind of response we’re sending(MIME types). Here are a couple of really common status codes:

404 - Not found
500 - Internal service error, meaning something happened on the server's end
200 - OK, the request has succeeded

You can see an full list of status codes here.

MIME types say what kind of document we’re sending, eg. a HTML document, a JS file, a CSV file. Here are are couple of common MIME types:

text/html - HTML document
image/vnd.microsoft.icon - .ico file(such as a favicon)
text/javascript - .js file

You can see an (almost) full list of MIME types here.

You can add a header to the code we just wrote to send out a request like this:

http.createServer((req, res) => { // Creating the server
// req is the user's request, and res is our response.
// Writing a header with a 200 status code and a plain text content type(MIME type):
res.writeHead(200, {"Content-Type": "text/plain"})
// We write to the response by using:
res.write("Hello there!") // Writes our response
res.end() // Closes the response, telling the user that we're done writing to the response and our response can be sent over.
})

Now let’s actually get this code functional and working by adding a .listen. This is going to make the server listen for connections at the port and host we specify(the hostname is automatically localhost).

const http = require("http"),
port = 3030,
hostname = "localhost"
http.createServer((req, res) => { // Creating the server
// req is the user's request, and res is our response.
// Writing a header with a 200 status code and a plain text content type(MIME type):
res.writeHead(200, {"Content-Type": "text/plain"})
// We write to the response by using:
res.write("Hello there!") // Writes our response
res.end() // Closes the response, telling the user that we're done writing to the response and our response can be sent over.
}).listen(port, hostname, () => { // Callback for .listen, tells us when the server starts listening for requests
console.log(`Listening on ${hostname}:${port}`)
})

Now, if you were to run this code using the following command:

node <my file name>.js

You should be able to go to localhost:3030 and see your plain text document your server sends out to any request:

localhost:3030

Now it’s time to start managing those requests properly.

In reality, when we navigate to the page localhost:3030, we are sending out a request with the URL “/”. If we navigated to the page localhost:3030/help, we are sending out a request with the URL “/help”. Right now, regardless of the URL, we are sending out the plain text “Hello there!” First off, let’s get our code reading the URL by adding the following line:

const http = require("http"),
port = 3030,
hostname = "localhost"
http.createServer((req, res) => { // Creating the server
// req is the user's request, and res is our response.
// Let's look at what specifically the user is requesting from us:
console.log(req.url)
// Writing a header with a 200 status code and a plain text content type(MIME type):
res.writeHead(200, {"Content-Type": "text/plain"})
// We write to the response by using:
res.write("Hello there!") // Writes our response
res.end() // Closes the response, telling the user that we're done writing to the response and our response can be sent over.
}).listen(port, hostname, () => { // Callback for .listen, tells us when the server starts listening for requests
console.log(`Listening on ${hostname}:${port}`)
})

Now, if we were to startup the server again and go over to localhost:3030/help, we should be able to see the URLs the browser is requesting:

Requests the browser is making to the backend

Okay. So, we see the /hello, as expected. But what about the /favicon.ico?

This is the icon that we see in pretty much every webpage we go to.

favicon.ico example

Now, what we’re going to do is create a folder in the current workspace we’re working in. I’m going to call mine Frontend and that’s what it will be called in my code, but you can call it whatever you would like. This is going to be where we handle all the requests the browser is making safely. Everything in Frontend the browser can ask for- anything outside of that is off limits. Make sure though to place your HTTP server code outside of the folder, though.

This folder is going to contain all the stuff we want to send to the browser. For example, we could have a GET request for the main page that redirects to Frontend/home.html, reading the file using fs for NodeJS and returning that to the user.

In addition, it would be relatively simple to handle other requests by taking the URL they are asking for and reading the file(within Frontend, or course).

const http = require("http"),fs = require("fs"), // File system for NodeJSport = 3030,hostname = "localhost"http.createServer((req, res) => { // Creating the server   // req is the user's request, and res is our response.   // Let's look at what specifically the user is requesting from us:   console.log(req.url)   let reqPath = "home.html" // This is the request path. It's by default home.html.   // If req.url is "/", they are requesting the home page. We'll read from Frontend home.html and write this to them.   if(req.method == "GET") {      // Making a GET request(ex. asking for a page, etc)      // Now this is where we handle if they want the home page      if(req.url !== "/") {         // This means the request is anything but the home page, so we are going to set reqPath to req.url.         reqPath = req.url      }      // Now we are going to open the file they are asking for(Frontend/reqPath), and send it to them.      fs.readFile(`Frontend/${reqPath}`, "utf8", (err, message) => {         if(err) throw err         res.writeHead(200, {"Content-Type": "text/html"}) // Automatically this, but we can make this more dynamic later.         res.write(message)         res.end() // Closes the response, telling the user that we're done writing to the response and our response can be sent over.      })   }}).listen(port, hostname, () => { // Callback for .listen, tells us when the server starts listening for requests   console.log(`Listening on ${hostname}:${port}`)})

This is a simple way for you to start up the server and be able to simply make a request for another page by making a GET request for the file name. However, to user this in production and to make it cleaner, there are a couple things we should change.

First off, there is a security vulnerability in this. While we attempted to fix those problems by allowing only requests in the folder Frontend, if you were to ask for “../passwordDatabase.csv,” the file reader would jump out of the folder Frontend and send that password database over. A quick way to fix this would be to check the path for the number of periods; if it is more than one, then we can write a 403(forbidden) error:

const http = require("http"),fs = require("fs"), // File system for NodeJSport = 3030,hostname = "localhost"http.createServer((req, res) => { // Creating the server   // req is the user's request, and res is our response.   // Let's look at what specifically the user is requesting from us:   console.log(req.url)   let reqPath = "home.html" // This is the request path. It's by default home.html.   // If req.url is "/", they are requesting the home page. We'll read from Frontend home.html and write this to them.   if(req.method == "GET") {      // Making a GET request(ex. asking for a page, etc)      // Now this is where we handle if they want the home page      if(req.url !== "/") {         // This means the request is anything but the home page, so we are going to set reqPath to req.url.         reqPath = req.url      }      // Now we are going to open the file they are asking for(Frontend/reqPath), and send it to them.      if(reqPath.split(".").length <= 2) {         fs.readFile(`Frontend/${reqPath}`, "utf8", (err, message) => {            if(err) throw err            res.writeHead(200, {"Content-Type": "text/html"}) // Automatically this, but we can make this more dynamic later.            res.write(message)            res.end() // Closes the response, telling the user that we're done writing to the response and our response can be sent over.         })      } else {         // Forbidden, they're trying to get out of the Frontend folder.         res.writeHead(403, {"Content-Type": "text/plain"})         res.write("403 ERROR: FORBIDDEN.")         res.end()      }   }}).listen(port, hostname, () => { // Callback for .listen, tells us when the server starts listening for requests   console.log(`Listening on ${hostname}:${port}`)})

It also can’t always send the file with the MIME type text/html, such as favicon.ico. In order to fix this, we could make an object which contains all the file endings we need and their corresponding MIME types.

const http = require("http"),fs = require("fs"), // File system for NodeJSport = 3030,hostname = "localhost",MIMETypes = {"html": "text/html","csv": "text/csv","css": "text/css","ico": "application/vnd.microsoft.icon","js": "text/javascript","txt": "text/plain","default": "application/octet-stream"} // A couple MIME Typeshttp.createServer((req, res) => { // Creating the server   // req is the user's request, and res is our response.   // Let's look at what specifically the user is requesting from us:   console.log(req.url)   let reqPath = "/home.html" // This is the request path. It's by default home.html.   // If req.url is "/", they are requesting the home page. We'll read from Frontend home.html and write this to them.   if(req.method == "GET") {      // Making a GET request(ex. asking for a page, etc)      // Now this is where we handle if they want the home page      if(req.url !== "/") {         // This means the request is anything but the home page, so we are going to set reqPath to req.url.         reqPath = req.url      }      splReq = reqPath.split(".") // Splitted up version of reqPath      // Now we are going to open the file they are asking for(Frontend/reqPath), and send it to them.      if(splReq.length <= 2) {         fs.readFile(`Frontend${reqPath}`, "utf8", (err, message) => {            if(err) {               res.writeHead(404, {"Content-Type": "text/plain"})               res.write("Error: Not found")               res.end() // Listening for 404 errors            } else {               res.writeHead(200, {"Content-Type": splReq[1] in MIMETypes ? MIMETypes[splReq[1]] : MIMETypes["default"]})               res.write(message)               res.end() // Closes the response, telling the user that we're done writing to the response and our response can be sent over.            }         })      } else {         // Forbidden, they're trying to get out of the Frontend folder.         res.writeHead(403, {"Content-Type": "text/plain"})         res.write("403 ERROR: FORBIDDEN.")         res.end()      }   }}).listen(port, hostname, () => { // Callback for .listen, tells us when the server starts listening for requests   console.log(`Listening on ${hostname}:${port}`)})

Now we know how to deal with simple GET requests; let’s use different requests. I would recommend using Postman for this because it makes adding headers simple and easy to do; however, you can create a home page which uses XHTTP or forms to do this instead.

For POST and PUT requests, there are two different ways of sending data. Either you can send it through the URL, or you could send it through the body of the request.

Sending through the body

For the body, we receive the data in chunks, and we need to create two listeners to add together all the chunks: data, for when we receive data, and end, for when the body is finished sending data over.

It’s formatted in URL formatting, in which the spaces are replaced with %20 and the different arguments are separated by the “?”. The simple way to fix this is with decodeURIComponent. We’re going to parse the string like so:

  1. Then split the string by “?”. This separates the different parameters. username=sarah&phone%20number=1023434434 => [“username=sarah”, “phone%20number=1023434434”]
  2. Loop through, push to an object, and format the parameter properly using decodeURIComponent.
let message = ""req.on("data", (chunk) => {   message += chunk.toString() // Listening for chunks of data and adding it to the message})req.on("end", () => {   message = message.split("&")   let fullMessage = {}, // For loop through   fullI   for(let i in message) { // Looping through and pushing to object       fullI = decodeURIComponent(message[i]).split("=") // Simple way to decode all the URL characters, ex. %20      fullMessage[fullI[0]] = fullI[1]   }   console.log(fullMessage) // Outputs the arguments in object format})

This turns the arguments passed into the body as an object.

Example of passing arguments through the body via Postman
Reading the arguments from the body of a PUT request

Sending through the URL

This same idea can be applied to sending data through the URL. The URL’s arguments can be seen by looking at req.url, except they aren’t read as chunks. The only difference is that this is put together with the URL to read, and those can be separated by splitting by “?”.

message = req.url.split("?")[1].split("&") // Splitting from beginning of URL and listing parameterslet fullMessage = {},fullIfor(let i in message) {   fullI = decodeURIComponent(message[i]).split("=") // Simple way to decode all the URL characters, ex. %20   fullMessage[fullI[0]] = fullI[1]}console.log(fullMessage)

Similarly, other requests such as DELETE and PATCH can be handled in a similar way, taking arguments from the body or URL and handling them. The way to differentiate these different requests is by creating a switch statement or simply an if statement that looks at req.method:

switch(req.method) {
case "GET":
// Handle GET requests
break
case "DELETE":
// Handle DELETE requests
break
case "POST":
// Handle POST requests
break
...etc
}

So ultimately, it is completely possible to write an HTTP server without the help of an external framework, and it helps understand the low-level processes of an HTTP request.

All photos were taken/created by the author unless otherwise noted.

The Startup

Get smarter at building your thing. Join The Startup’s +787K followers.

Sign up for Top 10 Stories

By The Startup

Get smarter at building your thing. Subscribe to receive The Startup's top 10 most read stories — delivered straight into your inbox, once a week. Take a look.

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

Sarah Cross

Written by

A student with a passion for coding.

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +787K followers.

Sarah Cross

Written by

A student with a passion for coding.

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +787K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store