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
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
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]
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"
}
}
Run the start command like so:
➜ 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:
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.
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, 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!")
},
})
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!
.
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' } }
)
}
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.
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}`
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})
}
Pretty simple.
- 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.
- Then, we extract the request method into the method variable, which can be one of the HTTP verbs.
- We then construct a string called
apiEndpoint
, concatenating the two values calculated above. - A switch block that tests against
apiEndpoint
, which effectively is our service router at this point. - Handlers for each route and method inside each
case
block. - 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).
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 }
)
We have to keep in mind:
- We always want to JSON stringify our response, this is to abide by the principle of RESTful services.
- We always want to have the
Content-Type
header asapplication/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})
}
}
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 })
}
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
}
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>
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
}
})
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})
}
Whoa. Lots to unpack here, so let's go through it step by step.
- We first get the request body by awaiting the
req.json()
. - Using the power of almighty
zod
, we parse the object and verify that it is indeed conforming to our definition of theFeatureFlag
zod validator. Note that we usesafeParse
instead ofparse
since the latter would throw an error and we'd have to handle that. - 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?
- 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. - If this file exists, we fetch the text, parse it into JSON, and update the object.
- Finally, we write the updated object back into the file.
That wraps up the PUT implementation of feature flags.
Implementing GET /flags
The GET endpoint logic is pretty simple.
- We check the request URL search params for a key name. If it does not exist, we return with a
400 Bad Request
response. - 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.
- 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 })
}
Now let's see it run!
Voila! We can also fetch this information from the service now.
Conclusion
In conclusion, we have learned how to:
- Create a HTTP server in Bun.js
- Tweak it to be a RESTful service
- Create a service level router per method and endpoint
- Implement the functionality of the feature flag service
- 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:
- Add a
DELETE /flags
endpoint which deletes the key provided. - Replace the File I/O with an in-memory database to speed up the service.
- Add a simple authentication to the feature flag service.
- Bundle up this project using
Bun.build
and host it in a cloud provider (or any server, Raspberry Pie, etc) of your choice.
Top comments (0)