TL;DR
This articles shows how I made bun-html-live-reload.
The code will refresh the browser everytime the server is hot-reloaded, by sending Server Sent Events (SSE).
Introduction
Building server-rendered website using Bun's builtin HTTP server is pretty easy.
// server.ts
Bun.serve({
fetch: () => {
return new Response("<h1>Hello, World!</h1>", {
headers: { "Content-Type": "text/html" },
});
},
});
You can run this code with hot reload enabled by running bun --hot server.ts
command. And now everytime you change the html content, Bun will automatically reload the server.
Problem with this approach is that, while the server is reloaded, the browser is not.
So you don't see the changes in the browser, and you have to manually refresh the page yourself.
Instead, we want to live reload our HTML like this.
In this article, we will build a simple live reload mechanism for HTML content,
so everytime you change the server.ts
file, or any other files that imported by it,
the browser will automatically refresh the page, and you will see the changes immediately.
The end result will look like this from user's perspective:
// server.ts
import { withHtmlLiveReload } from "bun-html-live-reload";
Bun.serve({
fetch: withHtmlLiveReload(async (req) => {
return new Response("<h1>Hello, World!</h1>", {
headers: { "Content-Type": "text/html" },
});
}),
});
The idea (Server Sent Events)
The idea is very simple. Everytime the server is reloaded, we will tell the browser to refresh the page.
We can either use WebSockets or Server Sent Events (SSE) to achieve this.
In this article we will use SSE for these reasons:
- The message is one way (server to browser), not bidirectional.
- No need to upgrade the connection like WebSocket.
Sending SSE message is pretty simple.
We will need to:
- Prepare endpoint to send the message.
- Inject SSE listener script to every HTML response.
Understanding Bun's hot reload
Before sending the reload message, we need to understand how Bun's hot reload works.
Let's run following code with bun --hot server.ts
:
// server.ts
let message = "hello";
console.log(message);
As expected, the server will print hello
to the console.
Without toucing the terminal, if we edit the message and save the file.
// server.ts
let message = "hello world";
console.log(message);
The server will print hello world
to the console.
So basically everytime we change the file, Bun will re-run all the top level code in the file.
In the next section, we will need to persist a variable between reloads.
To do that, we can use globalThis
.
globalThis.counter = globalThis.counter ?? 0;
globalThis.counter += 1;
console.log("Counter: ", counter);
Everytime we change our code, the counter will be increased by 1.
For type safetiness in typescript, we can define the type of the variable in a global scope.
declare global {
var counter: number | undefined;
}
globalThis.counter = globalThis.counter ?? 0;
globalThis.counter += 1;
console.log("Counter: ", counter);
Listening to SSE event
To make browser listen to SSE event, we will use EventSource
API.
The /__bun_live_reload
endpoint will be used as the source of the event.
Then we will use location.reload()
to refresh the browser everytime the event is received.
new EventSource("/__bun_live_reload").onmessage = () => {
location.reload();
};
We will inject this script to every HTML response on the later section.
// TODO: Inject this script to every HTML response
const liveReloadScript = `
<script>
new EventSource("/__bun_live_reload").onmessage = () => {
location.reload();
};
</script>
`;
Injecting the script
To inject the script to every HTML response, we will create a wrapper for fetch
function.
// bun-html-live-reload.ts
type Fetch = (req: Request) => Promise<Response>;
export function withHtmlLiveReload(handler: Fetch): Fetch {
return async (req) => {
const response = await handler(req);
// TODO: Inject the script to every HTML response
return response;
};
}
For simplicity of this tutorial, we will simply append the client's live reload script to the end of the HTML content.
export function withHtmlLiveReload(handler: Fetch): Fetch {
return async (req) => {
const response = await handler(req);
const htmlText = await response.text();
const newHtmlText = htmlText + liveReloadScript;
return new Response(newHtmlText, { headers: response.headers });
};
}
Sending the SSE message
To send the SSE message, we will need to create a new endpoint /__bun_live_reload
, and return ReadableStream
object as the response.
Don't forget to add text/event-stream
as the Content-Type
header.
export function withHtmlLiveReload(handler: Fetch): Fetch {
return async (req) => {
const response = await handler(req);
if (new URL(req.url).pathname === "/__bun_live_reload") {
const stream = new ReadableStream({
start(client) {},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
// ...
return response;
};
}
Then we can save the client
object as a global variable.
After that, on the next reload, we can send the message to the client by calling enqueue
on the top level.
// withHtmlLiveReload
const stream = new ReadableStream({
start(client) {
globalThis.client = client;
},
});
// withHtmlLiveReload
// This will send the message to the client
globalThis.client?.enqueue("data:\n\n");
The message string data:\n\n
is the minimum message that required to trigger the event. You can add more data here if you want to send it to the client.
Also you might want to add type definition for this
declare global {
var client: ReadableStreamDefaultController | undefined;
}
Putting it all together
Here is the full code of bun-html-live-reload.ts
:
// bun-html-live-reload.ts
declare global {
var client: ReadableStreamDefaultController | undefined;
}
type Fetch = (req: Request) => Promise<Response>;
globalThis.client?.enqueue("data:\n\n");
const liveReloadScript = `
<script>
new EventSource("/__bun_live_reload").onmessage = () => {
location.reload();
};
</script>
`;
export function withHtmlLiveReload(handler: Fetch): Fetch {
return async (req) => {
if (new URL(req.url).pathname === "/__bun_live_reload") {
const stream = new ReadableStream({
start(client) {
globalThis.client = client;
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
const response = await handler(req);
const htmlText = await response.text();
const newHtmlText = htmlText + liveReloadScript;
return new Response(newHtmlText, { headers: response.headers });
};
}
Top comments (0)