Writing a simple RESTful TypeScript web service with Bun.js
Tags: bunjs, webdev, restapi, typescript
Published: 2024-02-05 20:41:30

 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](https://bun.sh/docs/installation) and get bun installed. Execute the following command to see if bun is installed:

```zsh
➜  bun --version
1.0.21
```

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

```zsh
➜  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
```

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

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

 installed zod@3.22.4

 1 package installed [130.00ms]
```

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.

```json
{
    "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"
    }

}
```

Run the start command like so:

```zsh
➜  bun start
$ bun --hot run index.ts
Hello via Bun!
```

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

```typescript
console.log("Hello via Bun!")
console.log("Initializing the feature flag service")
```

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

```zsh
Hello via Bun!
Initializing the feature flag service
```

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](https://bun.sh/docs/api/http#reference), 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:
```typescript
Bun.serve({
    port: 8080,
    fetch(req) {
        return new Response("Bun!")
    },
})
```

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!`.

<img src="https://i.imgur.com/XXy4N80.png" alt="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:

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

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.

<img src="https://i.imgur.com/wnewMFb.png" alt="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.

```typescript
// Types
type Path = '/flags'
type Method = 'GET' | 'PUT'
type ApiEndpoint = `${Method} ${Path}`
```

`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.

```typescript
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})
}
```

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`:
<img src="https://i.imgur.com/iKkQjWE.png" alt="image of bun REST server output" />

`PUT flags`:
<img src="https://i.imgur.com/WdCkBL2.png" alt="image of bun REST server output" />

`DELETE flags`:
<img src="https://i.imgur.com/sRKnBem.png" alt="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:

```typescript
Response(
    JSON.stringify(someJSON), 
    { headers: { 'Content-Type': 'application/json' }, status: someNumber }
)
```

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:

```typescript
// 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})
    }
}
```

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:

```typescript
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 })
}
```

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:
```typescript
{
    key: string,
    value: boolean
}
```

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:

```typescript
import { z } from "zod"

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

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.

```typescript
Bun.serve({
    port: 8080,
    async fetch(req) {
    	// fetch implementation
    }
})
```

Now we can flesh out the PUT route:

```typescript
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})
}
```

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:
<img src="https://i.imgur.com/DMiTThf.gif" alt="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:

```typescript
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 })
}
```

Now let's see it run!

<img src="https://i.imgur.com/sqMwQSp.gif" alt="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](https://bun.sh/docs)
2. [Zod Docs](https://zod.dev/)
3. [Sample Code](https://github.com/nazeelashraf/blog-examples/tree/main/blog/002)