Create Your Own Live Browser Refresh In Deno
Craig Morten
Posted on January 15, 2022
In modern web development we've grown accustomed to rich developer experience features such as hot module replacement (HMR) from the likes of Webpack HMR and React Fast Refresh which allow us to iterate on our apps fast, without the pain of slow server restarts.
Ever wondered how this tooling might work? In this tutorial we will build a simple live browser refresh in Deno that demonstrates so of the basics!
Getting started 🤔
To begin you will need to install Deno and create a new directory to work in, e.g. ./refresh/
.
For this tutorial I am using Deno v1.17.3
, but the code should work with future versions of Deno as it doesn't require any external dependencies, provided there are no breaking changes to the Deno APIs (e.g. for v2
).
In your new directory create a mod.ts
file. This will act as the entrypoint for our Deno module, and contain all of our server-side code.
Watching files 👀
The first part of our live browser refresh module is a function to watch for file changes - this will later allow us to tell the browser to refresh when we make and save changes to our application.
/**
* Watch files from current directory
* and trigger a refresh on change.
*/
async function watch() {
// Create our file watcher.
const watcher = Deno.watchFs("./");
// Wait for, and loop over file events.
for await (const event of watcher) {
// TODO: trigger a browser refresh.
}
}
Here we define our initial watch()
function which makes use of the built-in Deno.watchFs(...)
method to watch for file system events anywhere within our current directory. We then loop over the events picked up by the watcher, where we will add code to trigger a browser refresh.
Before we move onto code for triggering browser refresh, it's worth taking a look at different file system events that can fire. Namely:
interface FsEvent {
flag?: FsEventFlag;
kind:
| "any"
| "access"
| "create"
| "modify"
| "remove"
| "other";
paths: string[];
}
It would be a bit annoying if our application reloaded every time a file was accessed but not necessarily changed. Let's update our loop to filter out some of these events.
for await (const event of watcher) {
// Skip the "any" and "access" events to reduce
// unnecessary refreshes.
if (["any", "access"].includes(event.kind)) {
continue;
}
// TODO: trigger a browser refresh.
}
WebSocket middleware 🏎
In this tutorial we will be using WebSockets to communicate the need to trigger a browser refresh. It is worth noting that you could also use Server Sent Events to achieve a similar result. If you try it out, do share in the comments below!
We will start with setting up the server-side WebSocket behaviour. For this we will create a small middleware function that will accept specific requests to the server, and upgrade the connection to a WebSocket.
/**
* Upgrade a request connection to a WebSocket if
* the url ends with "/refresh"
*/
function refreshMiddleware(req: Request): Response | null {
// Only upgrade requests ending with "/refresh".
if (req.url.endsWith("/refresh")) {
// Upgrade the request to a WebSocket.
const { response, socket } = Deno.upgradeWebSocket(req);
// TODO: handle the newly created socket.
return response;
}
// Leave all other requests alone.
return null;
};
Here our function first checks the request URL to see if it ends with "/refresh"
. If not we leave the request alone.
When we get a match we use the built-in Deno.upgradeWebSocket(...)
method to upgrade our connection to a WebSocket. This method returns an object including the response
that must be returned to the client for the upgrade to be successful, and a socket
instance.
Given we will be using the socket
instance as our means of instructing the client to refresh the browser, let's add some code to store the WebSocket as well as handle when it closes.
/**
* In-memory store of open WebSockets for
* triggering browser refresh.
*/
const sockets: Set<WebSocket> = new Set();
/**
* Upgrade a request connection to a WebSocket if
* the url ends with "/refresh"
*/
function refreshMiddleware(req: Request): Response | null {
if (req.url.endsWith("/refresh")) {
const { response, socket } = Deno.upgradeWebSocket(req);
// Add the new socket to our in-memory store
// of WebSockets.
sockets.add(socket);
// Remove the socket from our in-memory store
// when the socket closes.
socket.onclose = () => {
sockets.delete(socket);
};
return response;
}
return null;
};
We've now added an in-memory store for created WebSockets. When we upgrade the connection we add the new socket
to our store as well as a handler for removing the socket
from the store when it closes.
Triggering browser refresh 🙌
We're now ready to update our file watching code to trigger a browser refresh. We will do this by using the WebSockets created in our middleware to send a refresh event to the client.
/**
* Watch files from current directory
* and trigger a refresh on change.
*/
async function watch() {
const watcher = Deno.watchFs("./");
for await (const event of watcher) {
if (["any", "access"].includes(event.kind)) {
continue;
}
sockets.forEach((socket) => {
socket.send("refresh");
});
}
}
Here we loop over the sockets
in-memory store, and for each WebSocket we send our custom refresh event.
Finishing our server module 🧑💻
To finish off our server module we just need to tie our file watching and server middleware together. For this we create our refresh()
function module export which users can consume to their servers.
/**
* Constructs a refresh middleware for reloading
* the browser on file changes.
*/
export function refresh(): (req: Request) => Response | null {
watch();
return refreshMiddleware;
}
This final exported function ties our work together. First it starts the file watcher, and then returns the middleware that can be used to handle the refresh communications between the server and the browser.
Handling refresh events client-side 💥
Now we're all sorted on the server, let's hop over the some coding for the client. First we need to create a client.js
file to host our code.
Let's just dive in with the full code:
(() => {
let socket, reconnectionTimerId;
// Construct the WebSocket url from the current
// page origin.
const requestUrl = `${window.location.origin.replace("http", "ws")}/refresh`
// Kick off the connection code on load.
connect();
/**
* Info message logger.
*/
function log(message) {
console.info("[refresh] ", message);
}
/**
* Refresh the browser.
*/
function refresh() {
window.location.reload();
}
/**
* Create WebSocket, connect to the server and
* listen for refresh events.
*/
function connect(callback) {
// Close any existing sockets.
if (socket) {
socket.close();
}
// Create a new WebSocket pointing to the server.
socket = new WebSocket(requestUrl);
// When the connection opens, execute the callback.
socket.addEventListener("open", callback);
// Add a listener for messages from the server.
socket.addEventListener("message", (event) => {
// Check whether we should refresh the browser.
if (event.data === "refresh") {
log("refreshing...");
refresh();
}
});
// Handle when the WebSocket closes. We log
// the loss of connection and set a timer to
// start the connection again after a second.
socket.addEventListener("close", () => {
log("connection lost - reconnecting...");
clearTimeout(reconnectionTimerId);
reconnectionTimerId = setTimeout(() => {
// Try to connect again, and if successful
// trigger a browser refresh.
connect(refresh);
}, 1000);
});
}
})();
A lot going on here!
First we create some variables for storing the current WebSocket and a reconnection timer id. We then construct the url that will be used by the WebSocket for requests. Notice how it ends in /refresh
, just as we coded our server middleware function to detect and handle. Then we kick off the connection with a call to the connect(...)
method.
The connect(...)
function is where the majority of the work takes place. We ensure that any pre-existing sockets are closed - we want to avoid situations where there are more than one connection resulting in "double" refreshes! The WebSocket is then constructed using the request url, and a series of event listeners are setup to handle different WebSocket events:
- The main event listener is for
"message"
events. This receives messages from the server, and if it receives our custom refresh event, it fires a call to therefresh()
function which refreshes the browser. - The
"close"
event listener handles when we lose the connection from the server. This can happen easily with network blips (e.g. when you pass through a tunnel and lose signal!) so always good to handle. Here we setup a timeout to try and restart the connection again by callingconnect(...)
after a second delay. This time we pass therefresh
function as a callback to trigger a refresh once our connection is back. - Finally, the
"open"
event listener fires when the connection opens, and here we just execute the provided callback. This is used in the aforementioned reconnection logic to trigger the browser refresh when we get our connection back.
Congratulations!! 🥳 🎉
And we're done! Between the server mod.ts
and the browser client.js
we've now got all we need to successfully implement a live browser refresh on code change.
Don't believe me? Let's try it out!
First we will need to write a simple server to consume our new refresh module. Let's create a server.ts
:
import { serve } from "https://deno.land/std/http/server.ts";
import {
dirname,
fromFileUrl,
join,
} from "https://deno.land/std/path/mod.ts";
import { refresh } from "./mod.ts";
// Create useful file path variable for our code.
const __dirname = fromFileUrl(dirname(import.meta.url));
const clientFilePath = join(__dirname, "./client.js");
const indexFilePath = join(__dirname, "./index.html");
// Construct the refresh middleware.
const refreshMiddleware = refresh();
// Start a server on port `8000`.
serve((req: Request) => {
// Handle custom refresh middleware requests.
const res = refreshMiddleware(req);
if (res) {
return res;
}
// Handle requests for the client-side refresh code.
if (req.url.endsWith("client.js")) {
const client = Deno.readTextFileSync(clientFilePath);
return new Response(client, {
headers: {
"Content-Type": "application/javascript"
}
});
}
// Handle requests for the page's HTML.
const index = Deno.readTextFileSync(indexFilePath);
return new Response(index, {
headers: { "Content-Type": "text/html" }
});
});
console.log("Listening on http://localhost:8000");
This server code uses the Deno Standard Library for some server and path utilities. It constructs some variables storing the path to files the server needs to return, constructs the refresh middleware using the module that we've created in this tutorial, and then uses the standard library serve(...)
method to start a server on port 8000
.
We first call our refresh middleware with the request, and if we get a non-null response we return it - this means the request was for a WebSocket connection! Otherwise we handle requests for our client.js
code, and otherwise fallback to returning an index.html
. Let's create this index.html
file now:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Example Refresh App</title>
<style>
body {
background: #2c3e50;
font-family: Verdana, Geneva, Tahoma, sans-serif;
color: #ddd;
text-align: center;
font-size: 18px;
}
</style>
</head>
<body>
<script src="/client.js"></script>
<h1>Hello Deno!</h1>
</body>
</html>
And there we have it! Let's run our new server:
deno run --allow-read --allow-net ./server.ts
If we open a browser on http://localhost:8000
we should see our simple "Hello Deno!" webpage.
Now for the exciting part - let's see if the live browser refresh works! Head to your index.html
and try changing the text or some of the CSS. Notice anything different about the page in the browser? 💥
For an example of all this code working (and more!), check out the finished version at https://deno.land/x/refresh. 🦕
Written any cool Deno code lately? Perhaps you've built your own live browser refresh, or even HMR module that is worth a share?
Reach out on my twitter @CraigMorten, or leave a comment below! It would be great to hear from you! 🚀🚀
Posted on January 15, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.