Skip to content

Instantly share code, notes, and snippets.

@rosswintle
Created March 2, 2024 14:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rosswintle/ae6e47d52e4e5b06dfe197543be4c5eb to your computer and use it in GitHub Desktop.
Save rosswintle/ae6e47d52e4e5b06dfe197543be4c5eb to your computer and use it in GitHub Desktop.
JavaScript tool to poll for changes on the server and reload page or assets - based on Live.js
/*
Live.js - One script closer to Designing in the Browser
Written for Handcraft.com by Martin Kool (@mrtnkl).
Updated by Ross Wintle (https://rw.omg.lol) to:
- Use async/await
- Use fetch instead of XMLHttpRequest
- Be a JS class
- Use a separate, project-global modified-time file to check for changes
Version 4.
Recent change: Made stylesheet and mimetype checks case insensitive.
http://livejs.com
http://livejs.com/license (MIT)
@livejs
Include live.js#css to monitor css changes only.
Include live.js#js to monitor js changes only.
Include live.js#html to monitor html changes only.
Mix and match to monitor a preferred combination such as live.js#html,css
By default, just include live.js to monitor all css, js and html changes.
Live.js can also be loaded as a bookmarklet. It is best to only use it for CSS then,
as a page reload due to a change in html or css would not re-include the bookmarklet.
To monitor CSS and be notified that it has loaded, include it as: live.js#css,notify
*/
class Live {
constructor() {
this.headers = { "Etag": 1, "Last-Modified": 1, "Content-Length": 1, "Content-Type": 1 };
this.resources = {};
this.pendingRequests = {};
this.currentLinkElements = {};
this.oldLinkElements = {};
this.modifiedTimeUrl = '/.modified_time';
this.oldModifiedTime = 0;
this.interval = 1000;
this.loaded = false;
this.active = { "html": 1, "css": 1, "js": 1 };
}
// performs a cycle per interval
async heartbeat() {
if (document.body) {
// make sure all resources are loaded on first activation
if (!this.loaded) {
await this.loadresources();
}
await this.checkForChanges();
}
setTimeout(this.heartbeat.bind(this), this.interval);
}
// helper method to assert if a given url is local
isLocal(url) {
const loc = document.location,
reg = new RegExp("^\\.|^\/(?!\/)|^[\\w]((?!://).)*$|" + loc.protocol + "//" + loc.host);
return url.match(reg);
}
// loads all local css and js resources upon first activation
async loadresources() {
// gather all resources
var scripts = document.getElementsByTagName("script"),
links = document.getElementsByTagName("link"),
uris = [];
// track local js urls
for (var i = 0; i < scripts.length; i++) {
var script = scripts[i], src = script.getAttribute("src");
if (src && this.isLocal(src))
uris.push(src);
if (src && src.match(/\blive.js#/)) {
for (var type in this.active)
this.active[type] = src.match("[#,|]" + type) != null
if (src.match("notify"))
alert("Live.js is loaded.");
}
}
if (!this.active.js) uris = [];
if (this.active.html) uris.push(document.location.href);
// track local css urls
for (var i = 0; i < links.length && this.active.css; i++) {
var link = links[i], rel = link.getAttribute("rel"), href = link.getAttribute("href", 2);
if (href && rel && rel.match(new RegExp("stylesheet", "i")) && this.isLocal(href)) {
uris.push(href);
this.currentLinkElements[href] = link;
}
}
// initialize the resources info
for (var i = 0; i < uris.length; i++) {
var url = uris[i];
const info = await this.getHead(url);
this.resources[url] = info;
}
// initialize the modified time
const modifiedTime = await this.loadModifiedTime();
if (modifiedTime === false) {
// Set oldModifiedTime to the current timestamp
this.oldModifiedTime = new Date().getTime();
} else {
this.oldModifiedTime = modifiedTime;
}
// add rule for morphing between old and new css files
var head = document.getElementsByTagName("head")[0],
style = document.createElement("style"),
rule = "transition: all .3s ease-out;",
css = [".livejs-loading * { ", rule, " -webkit-", rule, "-moz-", rule, "-o-", rule, "}"].join('');
style.setAttribute("type", "text/css");
head.appendChild(style);
style.styleSheet ? style.styleSheet.cssText = css : style.appendChild(document.createTextNode(css));
// yep
this.loaded = true;
// console.log(this.resources)
}
// check all tracking resources for changes
async checkForChanges() {
let hasAResourceChanged = false;
for (var url in this.resources) {
if (this.pendingRequests[url])
continue;
let newInfo = await this.getHead(url);
let oldInfo = this.resources[url];
let hasChanged = false;
this.resources[url] = newInfo;
for (var header in oldInfo) {
// do verification based on the header type
var oldValue = oldInfo[header],
newValue = newInfo[header],
contentType = newInfo["Content-Type"];
switch (header.toLowerCase()) {
case "etag":
if (!newValue) break;
// fall through to default
default:
hasChanged = oldValue != newValue;
break;
}
// if changed, act
if (hasChanged) {
this.refreshResource(url, contentType);
hasAResourceChanged = true;
break;
}
}
}
// If no resource has changed, check if the modified time has changed
// and if it has, reload the page
if (!hasAResourceChanged) {
const modifiedTime = await this.loadModifiedTime();
if (modifiedTime !== false && modifiedTime > this.oldModifiedTime) {
this.oldModifiedTime = modifiedTime;
document.location.reload();
}
}
}
// act upon a changed url of certain content type
refreshResource(url, resourceType) {
switch (resourceType.toLowerCase()) {
// css files can be reloaded dynamically by replacing the link element
case "text/css":
var link = this.currentLinkElements[url],
html = document.body.parentNode,
head = link.parentNode,
next = link.nextSibling,
newLink = document.createElement("link");
html.className = html.className.replace(/\s*livejs\-loading/gi, '') + ' livejs-loading';
newLink.setAttribute("type", "text/css");
newLink.setAttribute("rel", "stylesheet");
newLink.setAttribute("href", url + "?now=" + (new Date().getTime()));
next ? head.insertBefore(newLink, next) : head.appendChild(newLink);
this.currentLinkElements[url] = newLink;
this.oldLinkElements[url] = link;
// schedule removal of the old link
this.removeOldLinkElements();
break;
// check if an html resource is our current url, then reload
case "text/html":
if (url != document.location.href)
return;
// local javascript changes cause a reload as well
case "text/javascript":
case "application/javascript":
case "application/x-javascript":
document.location.reload();
}
}
// removes the old stylesheet rules only once the new one has finished loading
removeOldLinkElements() {
var pending = 0;
for (var url in this.oldLinkElements) {
// if this sheet has any cssRules, delete the old link
try {
var link = this.currentLinkElements[url],
oldLink = this.oldLinkElements[url],
html = document.body.parentNode,
sheet = link.sheet || link.styleSheet,
rules = sheet.rules || sheet.cssRules;
if (rules.length >= 0) {
oldLink.parentNode.removeChild(oldLink);
delete oldLinkElements[url];
setTimeout(function () {
html.className = html.className.replace(/\s*livejs\-loading/gi, '');
}, 100);
}
} catch (e) {
pending++;
}
if (pending) setTimeout(this.removeoldLinkElements, 50);
}
}
//Loads the modified time from the server
async loadModifiedTime() {
let modifiedTime = false;
try {
const response = await fetch(this.modifiedTimeUrl);
if (response.ok) {
modifiedTime = await response.text();
}
} catch (error) {
console.error('Error:', error);
}
return parseInt(modifiedTime.trim(), 10);
}
// performs a HEAD request and passes the header info to the given callback
async getHead(url) {
let response;
this.pendingRequests[url] = true;
try {
response = await fetch(url, { method: 'HEAD' });
} catch (error) {
console.error(`Fetch Error: ${error}`);
}
if (!response.ok) throw new Error('Network response was not ok');
let info = {};
for (let h of Object.keys(this.headers)) {
let value = response.headers.get(h);
if (h.toLowerCase() === "etag" && value) value = value.replace(/^W\//, '');
if (h.toLowerCase() === "content-type" && value) value = value.replace(/^(.*?);.*?$/i, "$1");
info[h] = value;
}
delete this.pendingRequests[url];
return info;
}
}
if (document.location.protocol != "file:") {
if (!window.liveJsLoaded) {
var LiveJs = new Live();
LiveJs.heartbeat();
window.liveJsLoaded = true;
}
} else {
console.log("Live.js doesn't support the file protocol. It needs http.");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment