Skip to content

Instantly share code, notes, and snippets.

@nhrones
Last active September 13, 2023 18:01
Show Gist options
  • Save nhrones/85080a9cc993bf3629ceaf5bb8426a30 to your computer and use it in GitHub Desktop.
Save nhrones/85080a9cc993bf3629ceaf5bb8426a30 to your computer and use it in GitHub Desktop.
Hot Browser Refresh
//======================================
// server.ts
//======================================
import { inject } from './injector.ts' // SEE BELOW
import { registerWatcher } from './watcher.ts'
// in your server request handler ...
let { pathname } = new URL(request.url);
if (pathname.includes('/registerWatcher')) {
return registerWatcher(request)
}
// detect index.html request
let isIndex = false
if (pathname.endsWith("/")) {
isIndex = true
pathname += "index.html";
}
const fullPath = (targetFolder.length > 0)
? join(Deno.cwd(), targetFolder, pathname)
: join(Deno.cwd(), pathname);
// ... later ...
try {
// We intercept the index.html request so that we can
// inject our hot-refresh service script into it.
if (isIndex) {
const content = await Deno.readTextFile(fullPath)
const body = inject(content)
// create appropriate headers
const headers = new Headers()
headers.set("content-type", "text/html; charset=utf-8")
// We don't want to cache these, as we expect frequent dev changes
headers.append("Cache-Control", "no-store")
return new Response(body, { status: 200, headers });
} else {
// find the file -> return it in a response
const resp = await serveFile(request, fullPath)
resp.headers.append("Cache-Control", "no-store")
return resp
}
} catch (e) {
console.error(e.message)
return await Promise.resolve(new Response(
"Internal server error: " + e.message, { status: 500 }
))
}
// ...
// Watch for file changes
const fileWatch = Deno.watchFs('./');
const handleChange = debounce(
(event: Deno.FsEvent) => {
const { kind, paths } = event
const path = paths[0]
if (DEBUG) console.log(`[${kind}] ${path}`)
// we build from `src`
if (path.includes('/src')) {
console.log('esBuild Start!')
const cfg: buildCFG = {
entry: ["./src/main.ts"],
minify: MINIFY,
out: (targetFolder.length > 0) ? `./${targetFolder}/bundle.js` : './bundle.js'
}
console.log('esBuild Start!')
build(cfg).then(() => {
console.log('Built bundle.js!')
}).catch((err) => {
console.info('build err - ', err)
})
} // web app change
else {
const actionType = (path.endsWith("css"))
? 'refreshcss'
: 'reload'
console.log(`Action[${actionType}] sent to client!`)
const tempBC = new BroadcastChannel("sse");
tempBC.postMessage({ action: actionType, path: path });
tempBC.close();
}
}, 400,
);
// watch and handle any file changes
for await (const event of fileWatch) {
handleChange(event)
}
// end of server.ts
//======================================
// injector.ts
//======================================
/**
* This function recieves the raw text from reading the index.html file.
* We then replace the body-end-tag </body> tag with our custom SSE code.
*/
export const inject = (body: string) => {
const endOfBody = body.indexOf('</body>')
if (endOfBody > 5) {
const newBody = body.replace('</body>', `
<script id="injected">
(function () {
const events = new EventSource("/registerWatcher");
console.log("CONNECTING");
events.onopen = () => {
console.log("CONNECTED");
};
events.onerror = () => {
switch (events.readyState) {
case EventSource.CLOSED:
console.log("DISCONNECTED");
break;
}
};
events.onmessage = (e) => {
try {
const res = JSON.parse(e.data);
const { action } = res;
console.log("sse got action - ", action);
if (action === "refreshcss") {
console.log("refreshCSS()");
const sheets = [].slice.call(document.getElementsByTagName("link"));
const head = document.getElementsByTagName("head")[0];
for (let i = 0; i < sheets.length; ++i) {
const elem = sheets[i];
const parent = elem.parentElement || head;
parent.removeChild(elem);
const rel = elem.rel;
if (elem.href && typeof rel != "string" || rel.length == 0 || rel.toLowerCase() == "stylesheet") {
const url = elem.href.replace(/(&|\\?)_cacheOverride=d+/, "");
elem.href = url + (url.indexOf("?") >= 0 ? "&" : "?") + "_cacheOverride=" + new Date().valueOf();
}
parent.appendChild(elem);
}
} else if (action === "reload") {
console.log("Reload requested!");
window.location.reload();
}
} catch (err) {
console.info("err - ", err);
}
};
})();
</script>
</body>`);
return newBody
} else {
console.log('No </body> found!')
return body
}
}
// end of injector.ts
//=========================================
// watcher.ts
//=========================================
import { DEBUG } from './constants.ts'
const watcherChannel = new BroadcastChannel("sse");
/**
* This is the streaming service to handle browser refresh.
*
* The code we've injected into index.html registers for this SSE stream.
* The BroadcastChannel above, listens for messages from the servers
* file-change handler (./server.ts-line-97).
*
* This handler sends either a 'refreshcss' or a 'reload' action message.
* The injected code in index.html will then either do a stylesheet insert
* or call `window.location.reload()` to refresh the page.
*
* Below, we just stream all `action` messages to the browsers eventSource.
* See: ./injector.ts
*/
export function registerWatcher(_req: Request): Response {
if (DEBUG) console.info('Started SSE Stream! - ', _req.url)
const stream = new ReadableStream({
start: (controller) => {
// listening for bc messages
watcherChannel.onmessage = (e) => {
const { action, path } = e.data
if (DEBUG) console.log(`Watcher got ${action} from ${path}`)
const reply = JSON.stringify({ action: action })
controller.enqueue('data: ' + reply + '\n\n');
}
},
cancel() {
watcherChannel.close();
}
})
return new Response(stream.pipeThrough(new TextEncoderStream()), {
headers: { "content-type": "text/event-stream" },
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment