In this guide you'll learn about server-side vs client-side processing and how to set up an HTTP server to send responses to requests. You'll switch how your main website works to include some server-side processing.
Server-side processing is where clients (eg web browsers) make requests to servers, and code is run on the server to generate a response. Almost all web development we've done so far has been client-side: that is, JavaScript code that runs in the web browser, interacting with the DOM and responding to events in the browser. Traditionally, JavaScript only ran in the browser, and other languages like PHP, ASP.net, ColdFusion or Python would run on the server to generate the HTML page that would be sent to the client. Server-side JavaScript is now possible with Node.js (created 2009), Deno (2018) and Bun (2021), meaning the same language can be run on both client and server. We actually started learning JavaScript outside the web browser with a simple JavaScript file like this hello.js:
let x = 'world'
console.log(`Hello ${x}`) // Hello world
That simple code can be run on the CLI with bun hello.js. The script executes then reaches the end and terminates. That isn't a web server (aka an HTTP server). A web server needs to be constantly running, waiting for requests from clients. When it receives a request, it needs to reply with a response. Bun has a built-in function Bun.serve() which does just that. Here's a very simple hello-server.js that uses it:
let server = Bun.serve({
routes: {
'/*': () => new Response(`Welcome to CMS! ${Math.random()}`)
}
})
console.log(`Listening on ${server.url}`)
Run the file with bun hello-server.js and the script won't terminate. Instead, it will keep listening for requests, and you'll see a response by visiting http://localhost:3000/ in a browser. The Math.random() function runs server-side rather than client-side, which is called server-side processing.
With the server still running, in another file client.js add the following:
let res = await fetch('http://localhost:3000/')
console.log(res)
console.log(await res.text())
Run it with bun client.js (do this in a separate terminal window while the server is still running - let me know if you don't know how to do this in VSCode). This simple client makes a request to your server and outputs the response. Web browsers are usually the clients, but we can build CLI clients like this too. Notice that the response includes fields like 'status' and 'headers' as well as the 'Welcome to CMS!' message itself, which is the body of the response.
Terminate the server with CTRL+C.
This assumes you currently have a website in eg ~/code/website which you run locally with vite, and which runs on our shared server at a subdomain of compsci.me.
The shared server looks for a file called server.js in your repo, and if it doesn't find one it runs some Bun JavaScript code to serve up all files in your public folder. But, if you create your own server.js (in your website folder outside the public folder) then our shared server finds and runs that instead.
In the folder for your website (probably ~/code/website), make a new file server.js in the root of your repo (not in the public folder) and put this inside it:
let server = Bun.serve({
routes: {
// a dynamic route
'/hello': () => new Response('Hello world'),
// fallback for public folder inc /blah/ and /blah for /blah/index.html
'/*': async (req) => {
let path = new URL(req.url).pathname
let file, folder = './public'
if (/\/$/.test(path)) file = Bun.file(`${folder}${path}index.html`)
else {
file = Bun.file(`${folder}${path}`)
if (!(await file.exists())) file = Bun.file(`${folder}${path}/index.html`)
}
if (await file.exists()) return new Response(file)
else return new Response('404', { status: 404 })
}
},
})
console.log(`Server running at ${server.url}`)
Now, instead of running vite inside the public folder, you're going to run your website with bun server.js in the root of your repo. There are a couple of important differences vs vite:
CTRL+R to reload)5173 by default, while Bun uses port 3000 by defaultYou should see your whole website locally as before, but with an extra page at /hello which says 'Hello world'.
Commit and push your changes and check your website still works on your compsci.me subdomain. In particular, make sure it's picking up the new code by trying to access /hello and also check your home page and any other pages are still showing.
In addition to serving static files in your public folder (like .html files, CSS and images), you can now start creating server-side routes. These are functions that run on your server (using server-side processing) that receive requests from clients as parameters and return responses.
Open your server.js and find routes: {. You're going to be adding some lines of code below there by adding route handlers to the routes object.
// server-side processing to give the current time
'/now': () => new Response(Date.now()),
If you go to http://localhost:3000/now you might still see a 404 Not found page. That's because you first need to restart the server before you can see the page. You can avoid having to constantly restart the server by running bun --hot server.js instead of just bun server.js. Do that now and you should see the current time shown (as a UNIX timestamp) at http://localhost:3000/now . Refresh the page and you'll see the time changes... your function () => new Response(Date.now()) runs every time a request is made to /now, generating a new response each time.
Add this line right at the top of your file, before the let server = ...:
let startTime = Date.now()
Then add a couple more lines inside the routes object:
'/started': () => new Response(startTime),
'/online': () => new Response(`Online for ${((Date.now() - startTime)/1000).toFixed(0)}s`),
If you go to /now and keep refreshing you'll see the time keeps changing. But if you go to /started you'll see it doesn't change, and instead it shows the time the server last restarted. And /online shows you how many seconds since that restart.
So far you've been generating simple text responses, but it's quite possible to send back dynamic HTML pages in response to requests too:
'/hybrid': () => new Response(`<!doctype html>
<title>Dynamic page</title>
<style>body { background: red; color: white; }</style>
<h1>Random thing</h1>
<p>Server-side random: ${Math.random()}</p>
<p>Client-side random: <span id="client"></span></p>
<button onclick="doThing()">Click me</button>
<script>
function doThing () {
client.innerText = Math.random()
}
</script>
`, { headers: { 'Content-Type': 'text/html' } }),
Note the headers bit at the end... that's important, or the browser will treat it as plain text instead of HTML and just show the HTML as text instead of rendering it as HTML elements.
Access /hybrid and you'll see the page comes loaded with one random number which was generated on the server (via server-side processing), whilst you need to click a button to generate another random number in the browser (via client-side processing). The function doThing() is running client-side; the function Math.random() is running server-side.
All http responses should come with a status code. A code of 200 means 'OK' and indicates that the request was successful. The most well known status code is 404 which means a page wasn't found. You can find a full list of status codes and may be interested in what code 418 means there.
You can customise what appears on your 404 page by editing the line:
else return new Response('404', { status: 404 })
Notice the { status: 404 } in the line above. Without that, the server would send back a default 200 'OK' status code, but we want browsers to know the page wasn't found so need to send that 404 status code instead, which we can see by using the 'Network' tab of developer tools in our web browser.
Another option is to create a custom HTML file, say public/404.html:
<!doctype html>
<title>Page not found</title>
<h1>404 Not found</h1>
<p>Well... that sucks.</p>
Then send back the contents in that file whenever a page isn't found:
else return new Response(Bun.file(`./public/404.html`), { status: 404 })
fetch() to store a global high scorePreviously, you may have used localStorage.setItem('highScore', score) to persist eg a user's high score client-side in the browser. If they close their browser or even restart their computer, their high score is still available with localStorage.getItem('highScore'). However, it's only available to them - other users of the web page will have their own local high score.
With server-side processing you have the ability to store a global high score - the highest any player has achieved.
In client-side code, when a player has achieved their score, use fetch() to send a POST request to the server:
// ... eg when a user reaches 'Game over'
let score = 123
fetch('/submit-score', {
method: 'POST',
body: score
})
On the server, make a global variable let highScore = 0 to store the current high score (outside of Bun.serve()), then handle the POST request to the /submit-score route:
'/submit-score': {
POST: async (req) => {
let score = await req.text()
highScore = Math.max(highScore, score)
return new Response('ok')
}
},
Finally, we need a way to retrieve the high score from the server. When the game loads, instead of localStorage.getItem('highScore') we can fetch() it from the server:
let score = await (await fetch('/high-score')).text()
We'll update our /submit-score route on the server to handle both this new GET request, as well as the existing POST request:
'/submit-score': {
GET: () => new Response(highScore),
POST: async (req) => {
let score = await req.text()
highScore = Math.max(highScore, score)
return new Response('ok')
}
},
Note that your global high score won't persist server restarts. For that, we need to persist the data somewhere else... which we'll learn about in a later guide.
fetch() with JSON for structured dataIn the last section we were POST'ing a single score to the server. But usually, we want to send more data, and in a structured format. For that, we use JSON. We're going to modify our previous code to deal with a bit more information about the high score.
In our client-side code, here's the data we want to send to the server:
let data = {
name: 'Mr Gordon',
score: 315,
message: "I'm the best!"
}
And here's how we can send all that data to the server in one request:
fetch('/submit-score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
Notice that we had to add a 'header' to the request, to identify it as JSON rather than plain text. In addition, we had to JSON.stringify() the data we want to send, as requests can only continue text.
Back on the server:
// our default high score
let highScore = {
score: 0,
name: '',
message: '',
timestamp: Date.now()
}
// ...
// in Bun.serve()'s routes object...
'/submit-score': {
GET: () => Response.json(highScore),
POST: async (req) => {
let data = await req.json()
if (data.score > highScore) {
highScore.score = data.score
highScore.name = data.name
highScore.message = data.message
highScore.timestamp = Date.now()
}
return new Response('ok')
}
},
Notice that the GET request now uses Response.json() rather than new Response() for its response, which will add JSON content-type headers for you. And in the POST handler, we use req.json() instead of req.text().
Client-side, we also need to use the .json() method rather than the .text() method when fetching the structured high score data from the server:
let highScore = await (await fetch('/high-score')).json()
JSON gives you the ability to very easily share JavaScript objects between your clients and server. But requests always go from clients to servers, which generate responses. In a later guide we'll learn how to use websockets to create a 2-way channel between clients and servers so that either can send messages (usually as JSON) whenever they want.
Have a think about how you could incorporate server-side processing in your website, then code it up and ensure it works on your live website.
Do have a look at docs for Bun.serve() to help you understand how you can use it.
Have fun!