Last active
October 24, 2024 15:06
-
-
Save nhrones/85080a9cc993bf3629ceaf5bb8426a30 to your computer and use it in GitHub Desktop.
Hot Browser Refresh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//====================================== | |
// 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