DEV Community

Nazeel
Nazeel

Posted on

Writing a simple RESTful TypeScript web service with Bun.js

Bun.js is a JavaScript runtime written in Zig and powered by JavaScriptCore (which used in WebKit). It has native support for TypeScript, its own test runner, package manager, bundler and much much more.

In this blog post, I will attempt to guide you through the process of writing a simple feature flag RESTful web service with Bun.js and zod.

Yes, you read that right. No other dependencies, frameworks or libraries would be required to get this up and running.

Prerequisites

Not much! Just some basic knowledge around RESTful services, JavaScript, TypeScript will do. I will try my best to explain each step as descriptively as possible.

Remember when I said no other dependencies? I lied... sorta. You also need an API client like cURL, Postman or Insomnia.

Setting up

First, head over to the Bun installation and get bun installed. Execute the following command to see if bun is installed:

➜  bun --version
1.0.21
Enter fullscreen mode Exit fullscreen mode

Awesome! We can now scaffold a simple bun project by running bun init command:

➜  bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (feature-flag-service): 
entry point (index.ts): 

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts
Enter fullscreen mode Exit fullscreen mode

Simple as that! Now let's install zod, of which more will be revealed further down this article.

➜  bun add zod
bun add v1.0.21 (837cbd60)

 installed zod@3.22.4

 1 package installed [130.00ms]
Enter fullscreen mode Exit fullscreen mode

Amazing! Now let's create add a script that can start bun in hot reload mode so that we can have the latest version of our service without having to stop and start.

{
    "name": "feature-flag-service",
    "module": "index.ts",
    "type": "module",
    "devDependencies": {
        "@types/bun": "latest"
    },
    "peerDependencies": {
        "typescript": "^5.0.0"
    },
    "scripts": {
        "start": "bun --hot run index.ts"
    },
    "dependencies": {
        "zod": "^3.22.4"
    }

}
Enter fullscreen mode Exit fullscreen mode

Run the start command like so:

➜  bun start
$ bun --hot run index.ts
Hello via Bun!
Enter fullscreen mode Exit fullscreen mode

Let's update the index.ts file and see if bun hot reloads the file:

console.log("Hello via Bun!")
console.log("Initializing the feature flag service")
Enter fullscreen mode Exit fullscreen mode

Save the file, and you should see that the terminal updates with both the console logs printed.

Hello via Bun!
Initializing the feature flag service
Enter fullscreen mode Exit fullscreen mode

With that, we have our setup completed!

Creating a HTTP server in Bun

To create and HTTP server in Bun, the Bun.serve function can be used. This takes in an object with many parameters, but we are interested in 2 of them. Namely fetch; which handles the request and port which designates the port at which to listen to.

Let's add that into the end of the index.ts file:

Bun.serve({
    port: 8080,
    fetch(req) {
        return new Response("Bun!")
    },
})
Enter fullscreen mode Exit fullscreen mode

Give yourself a pat on the back because you just created a HTTP server! Save this and go to localhost:8080 to confirm. You should see a webpage that displays Bun!.

image of bun server output

Making it RESTful

If you inspect the response in the inspector, you will see that Bun is just wrapping whatever response we give pass to the Response constructor in a pre HTML tag.

Our service is going to be RESTful, so we should only send and receive in JSON. To make our server return a JSON, let's make some changes to the fetch function:

fetch(req) {
    return new Response(
        JSON.stringify({ message: 'Hello!' }),
        { headers: { 'Content-Type': 'application/json' } }
    )
}
Enter fullscreen mode Exit fullscreen mode

Now if you refresh the webpage, you can see that the response has changed into JSON and depending on your browser, it should render either the JSON view or the plaintext JSON.

image of bun REST server output

Feature flag service

A feature flag is a string key boolean value pair that determines if the key feature is enabled or not based on value. A feature flag service exposes endpoints that help you create/update and read feature flag values. These correspond to the PUT and GET HTTP verbs. The endpoint would be something like localhost:8080/flags.

Let's add some types at the top of the file to help us determine what type of call the client is trying to make.

// Types
type Path = '/flags'
type Method = 'GET' | 'PUT'
type ApiEndpoint = `${Method} ${Path}`
Enter fullscreen mode Exit fullscreen mode

ApiEndpoint will be one of PUT /flags or GET /flags.

Routing

Let's update the fetch function to determine what type of call is coming through.

try {
    const url = new URL(req.url)
    const method = req.method

    const apiEndpoint: ApiEndpoint = `${method as Method} ${url.pathname as Path}`

    switch(apiEndpoint) {
        case 'PUT /flags':
            return new Response(
                JSON.stringify({ message: `You called PUT /flags` }),
                { headers: { 'Content-Type': 'application/json' }, status: 200 }
            )
        case 'GET /flags': 
            return new Response(
                JSON.stringify({ message: `You called GET /flags` }),
                { headers: { 'Content-Type': 'application/json' }, status: 200 }
            )
        default:
            return new Response(
                JSON.stringify({ message: `You called ${apiEndpoint}, which I don't know how to handle!` }),
                { headers: { 'Content-Type': 'application/json' }, status: 404 }
            )
    }
} catch(err) {
    console.log(err)
    return new Response(JSON.stringify({ message: 'Internal Server Error' }), { headers: { 'Content-Type': 'application/json' }, status: 500})
}
Enter fullscreen mode Exit fullscreen mode

Pretty simple.

  1. We create a URL object by passing the request URL into the URL constructor. This gives us a handy way to access the URL, its params, and so on.
  2. Then, we extract the request method into the method variable, which can be one of the HTTP verbs.
  3. We then construct a string called apiEndpoint, concatenating the two values calculated above.
  4. A switch block that tests against apiEndpoint, which effectively is our service router at this point.
  5. Handlers for each route and method inside each case block.
  6. Wrap this all in a nice try-catch block to catch any runtime errors for the implementations to follow!

Okay, let's try to access the endpoint now with out API client of choice (I'm using Insomnia, because it's 1:24 AM as I type this).

GET /flags:
image of bun REST server output

PUT flags:
image of bun REST server output

DELETE flags:
image of bun REST server output

With that, we have created a service level router for all incoming requests. Now we can define each of our route functionalities more clearly...

... but before that, a bit of refactoring!

You may have noticed in the last code block, we blatantly violated the DRY principle in the form of all those 4 Response objects we created in each return statement. The pattern seems to be:

Response(
    JSON.stringify(someJSON), 
    { headers: { 'Content-Type': 'application/json' }, status: someNumber }
)
Enter fullscreen mode Exit fullscreen mode

We have to keep in mind:

  1. We always want to JSON stringify our response, this is to abide by the principle of RESTful services.
  2. We always want to have the Content-Type header as application/json, due to the same reason above.

We could create a class that wraps some of these repeated functionalities so it's easier to use. At the top of the file, below the types, add the following:

// Constants
const responseHeaders = { headers: { 'Content-Type': 'application/json' } }

// Custom classes
class CustomResponse extends Response {
    constructor(response: Record<any, any>, headerOverride?: Bun.ResponseInit) {
        super(JSON.stringify(response), {...responseHeaders, ...headerOverride})
    }
}
Enter fullscreen mode Exit fullscreen mode

This class extends the response class and does the JSON.stringify and header override for us. Now we can replace the fetch code with the following:

try {
    const url = new URL(req.url)
    const method = req.method

    const apiEndpoint: ApiEndpoint = `${method as Method} ${url.pathname as Path}`

    switch(apiEndpoint) {
        case 'PUT /flags':
            return new CustomResponse({ message: `You called PUT /flags` }, { status: 200 })
        case 'GET /flags': 
            return new CustomResponse({ message: `You called GET /flags` }, { status: 200 })
        default:
            return new CustomResponse({ message: `You called ${apiEndpoint}, which I don't know how to handle!` }, { status: 404 })
    }
} catch(err) {
    console.log(err)
    return new CustomResponse({ message: 'Internal Server Error' }, { status: 500 })
}
Enter fullscreen mode Exit fullscreen mode

Wow, so much cleaner and readable!

Implementing PUT /flags

When accepting a new flag (or updation to an existing flag) We would like the PUT payload to follow a certain shape:

{
    key: string,
    value: boolean
}
Enter fullscreen mode Exit fullscreen mode

We could use something like Object.hasOwnProperty to verify this, then assert that each key is a typeof boolean or string. Or, we could use zod, a static type inference as well as a runtime validator for a request object.

At the top, add the following code:

import { z } from "zod"

// Other types
const FeatureFlag = z.object({
    key: z.string().min(1),
    value: z.boolean()
})
type FeatureFlagType = z.infer<typeof FeatureFlag>
Enter fullscreen mode Exit fullscreen mode

With this, we say that FeatureFlag is an object that has a key of type string with length of at least 1 and a value of type boolean. We can then infer the type into a static TS type by using z.infer.

Before we proceed, let's make our fetch function async so we can do a lot of the asynchronous tasks required to get the PUT /flags running.

Bun.serve({
    port: 8080,
    async fetch(req) {
        // fetch implementation
    }
})
Enter fullscreen mode Exit fullscreen mode

Now we can flesh out the PUT route:

case 'PUT /flags': {
    const request = await req.json() as FeatureFlagType
    if (!FeatureFlag.safeParse(request).success) {
        return new CustomResponse({ message: 'Bad Request' }, { status: 400 })
    }

    const featureFlagToInsert = {
        [request.key]: request.value
    }

    let updatedFeatureFlagInfo = featureFlagToInsert

    // write to file
    try {
        const featureFlagFile = Bun.file('feature_flags.json', { type: "application/json" })

        if (await featureFlagFile.exists()) {
            const featureFlagObject = JSON.parse(await featureFlagFile.text())
            updatedFeatureFlagInfo = {
                ...featureFlagObject,
                ...updatedFeatureFlagInfo
            }
        }

        Bun.write(featureFlagFile, JSON.stringify(updatedFeatureFlagInfo))
    } catch(err) {
        console.log(err)
        return new CustomResponse({ message: 'Internal Server Error' }, { status: 500})
    }

    return new CustomResponse({ message: 'Created' }, { status: 201})
}
Enter fullscreen mode Exit fullscreen mode

Whoa. Lots to unpack here, so let's go through it step by step.

  1. We first get the request body by awaiting the req.json().
  2. Using the power of almighty zod, we parse the object and verify that it is indeed conforming to our definition of the FeatureFlag zod validator. Note that we use safeParse instead of parse since the latter would throw an error and we'd have to handle that.
  3. We then change the format so that we can insert the feature flag into our global feature flag object. But where do we store it? How can it be persisted?
  4. Bun's file I/O comes swooping in to the rescue. Leveraging Bun.file which lazy loads an instance of a file, we create one.
  5. If this file exists, we fetch the text, parse it into JSON, and update the object.
  6. Finally, we write the updated object back into the file.

Let's see it in action:
put flags demonstration

That wraps up the PUT implementation of feature flags.

Implementing GET /flags

The GET endpoint logic is pretty simple.

  1. We check the request URL search params for a key name. If it does not exist, we return with a 400 Bad Request response.
  2. If the feature flag file exists, we parse it and fetch the key from the feature flag object. If it does not, we simply return that the key is not registered.
  3. The fetched key is then converted into the FeatureFlagType type and returned.

So it'll look something like this:

case 'GET /flags': {
    const featureFlagFile = Bun.file('feature_flags.json', { type: "application/json" })

    const key = url.searchParams.get('key')

    if (!key) {
        return new CustomResponse({ message: 'Key missing in request' }, { status: 400 })
    }

    if (await featureFlagFile.exists()) {
        const featureFlagObject = JSON.parse(await featureFlagFile.text())
        const value = featureFlagObject[key]

        if (value === undefined) {
            return new CustomResponse({ message: 'Key is not registered' }, { status: 404 })
        }

        const response: FeatureFlagType = {
            key,
            value
        }

        return new CustomResponse(response, { status: 200})
    }

    return new CustomResponse({ message: 'Key is not registered' }, { status: 404 })
}
Enter fullscreen mode Exit fullscreen mode

Now let's see it run!

put flags demonstration

Voila! We can also fetch this information from the service now.

Conclusion

In conclusion, we have learned how to:

  1. Create a HTTP server in Bun.js
  2. Tweak it to be a RESTful service
  3. Create a service level router per method and endpoint
  4. Implement the functionality of the feature flag service
  5. Leverage Bun's File I/O to persist the feature flag information

Additional challenges

If you have been following along so far (you rock!) and still want to do more, I invite you to try to:

  1. Add a DELETE /flags endpoint which deletes the key provided.
  2. Replace the File I/O with an in-memory database to speed up the service.
  3. Add a simple authentication to the feature flag service.
  4. Bundle up this project using Bun.build and host it in a cloud provider (or any server, Raspberry Pie, etc) of your choice.

References:

  1. Bun Docs
  2. Zod Docs
  3. Sample Code

Top comments (0)