Skip to content

Instantly share code, notes, and snippets.

@rosswintle
Created March 2, 2024 15:23
Show Gist options
  • Save rosswintle/d1f6428685be5eb1091dab2246015ff2 to your computer and use it in GitHub Desktop.
Save rosswintle/d1f6428685be5eb1091dab2246015ff2 to your computer and use it in GitHub Desktop.
A PHP file watching watching server and JS reloading script for local development
/*
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 EventSource for server-sent events
- Use async/await
- Use fetch instead of XMLHttpRequest for requests
- Be a JS class
- Use a hash of the HTML to check for content changes
- Use manually configured monitors (specify JS, CSS, HTML in the constructor)
- Update the CSS change detection to actually work with modern browsers
Comments from the original Live.js:
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() {
/**
* The URL of the watch script's server
*
* @type {string}
*/
this.monitorUrl = 'http://localhost:8008';
/**
* Set up the EventSource object
*
* @type {EventSource}
*/
this.eventSrc = new EventSource(this.monitorUrl);
this.eventSrc.addEventListener('buildComplete', (event) => this.heartbeat());
/**
* The headers to check for changes
*
* @type {Array<string>}
*/
this.headers = [ "Etag", "Last-Modified", "Content-Length", "Content-Type" ];
/**
* The list of resource to monitor
*
* @type {object}
*/
this.resources = {};
/**
* The hash of the current page's HTML
*
* @type {number|boolean}
*/
this.htmlHash = false;
/**
* The list of pending requests
*
* @type {object}
*/
this.pendingRequests = {};
/**
* The list of current link elements for CSS files
*
* @type {object}
*/
this.currentLinkElements = {};
/**
* The list of old link elements for CSS files
*
* @type {object}
*/
this.oldLinkElements = {};
/**
* The script's loaded state
*
* @type {boolean}
*/
this.loaded = false;
/**
* Which monitors are active
*
* @type {object}
*/
this.active = { "html": 1, "css": 1, "js": 1 };
}
/**
* The main checking function that runs when an event is received to say that
* something has changed.
*/
async heartbeat() {
if (document.body) {
// make sure all resources are loaded on first activation
if (!this.loaded) {
await this.loadresources();
}
await this.checkForChanges();
}
}
/**
* Helper method to assert if a given url is local
*
* @param {string} url
* @return {boolean}
*/
isLocal(url) {
const loc = document.location;
const urlObject = new URL(url, loc.href);
return urlObject.origin === loc.origin;
}
/**
* Loads all local css and js resources upon first activation
*/
async loadresources() {
let uris = [];
// Set the HTML hash if we're tracking HTML
if (this.active.html) {
this.htmlHash = await this.getCurrentPageHash();
}
// Track local JS URLs
if (this.active.js) {
const scripts = this.getLocalScriptUris();
uris.push(...scripts)
}
// Track local CSS URLs
if (this.active.css) {
const cssLinks = this.getLocalCssUris();
uris.push(...cssLinks)
}
// Initialize the resources info
uris.forEach(
( async url => {
const info = await this.getHead(url);
this.resources[url] = info;
} ).bind(this)
)
// Add styles for morphing between old and new css files
const head = document.getElementsByTagName("head")[0];
const style = document.createElement("style");
const rule = "transition: all .3s ease-out;";
const css = `.livejs-loading * { ${rule} }`;
style.setAttribute("type", "text/css");
head.appendChild(style);
style.appendChild(document.createTextNode(css));
// We are loaded!
this.loaded = true;
}
/**
* Returns an array of uri's for all local scripts
*
* @returns {Array<string>}
*/
getLocalScriptUris() {
const uris = [];
const scripts = document.getElementsByTagName("script");
Array.from(scripts).forEach(script => {
const src = script.getAttribute("src");
if (src && this.isLocal(src)) {
uris.push(src);
}
})
return uris;
}
/**
* Returns an array of uri's for all local css files
*
* @returns {Array<string>}
*/
getLocalCssUris() {
const uris = [];
const links = document.getElementsByTagName("link");
Array.from(links).forEach(link => {
const rel = link.getAttribute("rel");
const href = link.getAttribute("href");
if (href && rel && rel.match(new RegExp("stylesheet", "i")) && this.isLocal(href)) {
uris.push(href);
// TODO: What is this for?
this.currentLinkElements[href] = link;
}
} )
return uris;
}
/**
* Checks all tracking resources for changes
*/
async checkForChanges() {
// Get the new content hash and reload if it has changed
if (this.active.html) {
const newHash = await this.getCurrentPageHash();
if (newHash !== this.htmlHash) {
document.location.reload();
}
}
// Iterate over all resources and check for changes
Object.keys(this.resources).forEach(async (url) => {
await this.checkResourceForChanges(url);
})
}
/**
* Checks a resource for changes
*
* @param {string} url
*/
async checkResourceForChanges(url) {
if (this.pendingRequests[url]) {
return;
}
let newInfo = await this.getHead(url);
let oldInfo = this.resources[url];
let contentType = newInfo["Content-Type"];
let hasChanged = false;
this.resources[url] = newInfo;
for (var header in oldInfo) {
// Do verification based on the header type
let oldHeaderValue = oldInfo[header];
let newHeaderValue = newInfo[header];
if (header.toLowerCase() === "etag" && !newHeaderValue) {
continue;
}
if (oldHeaderValue != newHeaderValue) {
hasChanged = true;
break;
}
}
// If changed, act
if (hasChanged) {
this.refreshResource(url, contentType);
}
}
/**
* Act upon a changed url of certain content type
*
* @param {*} url
* @param {*} resourceType
* @returns
*/
refreshResource(url, resourceType) {
switch (resourceType.toLowerCase()) {
// CSS files can be reloaded dynamically by replacing the link element
case "text/css":
// debugger;
let link = this.currentLinkElements[url];
let html = document.documentElement;
let head = link.parentNode;
let newLink = document.createElement("link");
html.className = html.className.replace(/\s*livejs\-loading/gi, '') + ' livejs-loading';
newLink.type = 'text/css';
newLink.rel = 'stylesheet';
newLink.href = url + "?now=" + (new Date().getTime());
link.after(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.
*
* The delayed retrys on deleting links are to allow the transition animation to happen
* when the new sheet loads. It waits until the new stylesheet is loaded before
* deleting the old one. There is no event that tells us when a stylesheet is loaded!
*/
removeOldLinkElements() {
// debugger;
let pending = 0;
for (var url in this.oldLinkElements) {
// Check if the new links has finished loading
const link = this.currentLinkElements[url];
if (!link.sheet) {
pending++;
continue;
}
// The new link has finished loading. We can delete the old link now.
const oldLink = this.oldLinkElements[url];
const html = document.documentElement;
oldLink.remove();
delete this.oldLinkElements[url];
// Wait for animations to complete. This is a guess/hack.
setTimeout(function () {
html.className = html.className.replace(/\s*livejs\-loading/gi, '');
}, 100);
}
// Retry if the new sheets weren't all loaded.
if (pending) {
setTimeout(this.removeOldLinkElements.bind(this), 50);
};
}
/**
* Loads the current page and returns a hash of the content
*
* @returns {Promise<number|boolean>}
*/
async getCurrentPageHash() {
const html = await this.getNewHtml();
if (!html) {
return false;
}
return this.hashCode(html);
}
/**
* Fetches up to date HTML for the current page
*
* @returns {Promise<string|boolean>}
*/
async getNewHtml() {
try {
const response = await fetch(document.location.href, { method: 'GET' });
if (response.ok) {
return await response.text();
}
} catch (error) {
console.error('Error:', error);
}
return false;
}
/**
* Performs a HEAD request and returns the header info
*
* @param {string} url
* @return {Promise<object>}
*/
async getHead(url) {
let response;
this.pendingRequests[url] = true;
try {
response = await fetch(url, { method: 'HEAD' });
} catch (error) {
console.error(`Fetch Error: ${error}`);
return {};
}
if (!response.ok) throw new Error('Network response was not ok');
let headerInfo = {};
// Process the headers that we want to check
this.headers.forEach((header) => {
let value = response.headers.get(header);
if (header.toLowerCase() === "etag" && value) {
value = value.replace(/^W\//, '');
}
if (header.toLowerCase() === "content-type" && value) {
value = value.replace(/^(.*?);.*?$/i, "$1");
}
headerInfo[header] = value;
})
delete this.pendingRequests[url];
return headerInfo;
}
/*
* Generates a simple hash of the string
*
* @param {string} str
* @return {number}
*/
hashCode(str) {
let hash = 0;
for (let i = 0, len = str.length; i < len; i++) {
let chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
}
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.");
}
<?php
/**
* A file watch script that sends server-side events to the client when a file is modified.
*
* Requires a build script: `build.sh`
*
* This will create files called `.modified_time` - be sure to add this to your `.gitignore` file.
*
* Events sent are:
* - ping: A ping event to keep the connection alive - no data is sent.
* - buildComplete: A build has completed. This sends a JSON object with a timestamp of the build: { timestamp: 1234567890 }
*/
// Directories to watch - modify if necessary
$directories = [
'src',
];
$modifiedFilename = '.modified_time';
$projectModifiedFilename = 'public/' . $modifiedFilename;
function getDirectoryModifiedTime($directory) {
// Check if we are running on MacOS
if (PHP_OS === 'Darwin') {
return getDirectoryModifiedTimeMac($directory);
} else {
return getDirectoryModifiedTimeLinux($directory);
}
}
/**
* Get the modified time of the most recently modified file in a directory (MacOS version).
*
* @param string $directory The directory to check.
* @return int
*/
function getDirectoryModifiedTimeMac($directory) {
global $modifiedFilename;
exec ("find \"$directory\" -type f -not -name \"$modifiedFilename\" -exec stat -f '%m' {} \; | sort -n | tail -1", $output);
return (int)$output[0];
}
/**
* Get the modified time of the most recently modified file in a directory (Linux version).
*
* @param string $directory
* @return int
*/
function getDirectoryModifiedTimeLinux($directory) {
global $modifiedFilename;
exec ("find \"$directory\" -type f -not -name \"$modifiedFilename\" -exec stat -c '%Y' {} \; | sort -n | tail -1", $output);
return (int)$output[0];
}
header("Cache-Control: no-store");
header("Content-Type: text/event-stream");
header('Access-Control-Allow-Origin: *');
$lastModifiedTime = 0;
$lastRunTime = 0;
exec('./build.sh');
$lastRunTime = time();
ob_implicit_flush(true);
ob_end_flush();
if (! file_exists($projectModifiedFilename)) {
file_put_contents($projectModifiedFilename, 0);
} else {
$lastModifiedTime = (int)(file_get_contents($projectModifiedFilename));
}
while (true) {
if (connection_aborted() === 1) {
break;
}
echo "event: ping\n\n";
foreach ($directories as $directory) {
$modifiedTime = getDirectoryModifiedTime($directory);
if ($modifiedTime > $lastModifiedTime) {
$lastModifiedTime = $modifiedTime;
}
}
if ($lastModifiedTime > $lastRunTime) {
exec('./build.sh');
file_put_contents($projectModifiedFilename, $lastModifiedTime);
$lastRunTime = $lastModifiedTime;
echo "event: buildComplete\n";
echo "data: " . json_encode(['timestamp' => $lastRunTime]) . "\n\n";
}
sleep(1);
}
echo "Starting PHP watch server on localhost:8008"
echo "Press Ctrl+\ to stop the server"
php -S localhost:8008 watch.php
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment