DEV Community

Cover image for Live reloading HTML with Bun
aabccd021
aabccd021

Posted on

Live reloading HTML with Bun

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" },
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

You can run this code with hot reload enabled by running bun --hot server.tscommand. 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" },
    });
  }),
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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>
`;
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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 });
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 });
  };
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)