Created
March 2, 2024 14:56
-
-
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
This file contains 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
/* | |
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