Commit 70f5676d authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Live reload dev tool for the Hypothesis client

This implements support and a test environment
in which the Hypothesis client can live reload
when scripts, styles etc. are changed.

Live reloading when scripts change currently requires
support from the hosting page.

There are two parts to this:

 - A server which serves test pages
   with the Hypothesis client embedded and notifies
   it when front-end assets (styles, scripts) are changed.

   Test pages are served at http://localhost:3000/<any path>
   by default.

 - A small client script which connects to the server and
   listens for notifications of asset changes.

   When styles change it will reload styles for the page.

   When scripts or Angular templates change it will send a
   request to the top-level page to reload.

   This enables reloading to work when changing
   both the sidebar app and Annotator code but also avoids
   the need to manually unload the existing Annotator instance
   before reloading.
parent b1989cf8
...@@ -16,9 +16,10 @@ var gulpUtil = require('gulp-util'); ...@@ -16,9 +16,10 @@ var gulpUtil = require('gulp-util');
var postcss = require('gulp-postcss'); var postcss = require('gulp-postcss');
var sass = require('gulp-sass'); var sass = require('gulp-sass');
var sourcemaps = require('gulp-sourcemaps'); var sourcemaps = require('gulp-sourcemaps');
var through = require('through2');
var manifest = require('./scripts/gulp/manifest');
var createBundle = require('./scripts/gulp/create-bundle'); var createBundle = require('./scripts/gulp/create-bundle');
var manifest = require('./scripts/gulp/manifest');
var vendorBundles = require('./scripts/gulp/vendor-bundles'); var vendorBundles = require('./scripts/gulp/vendor-bundles');
var IS_PRODUCTION_BUILD = process.env.NODE_ENV === 'production'; var IS_PRODUCTION_BUILD = process.env.NODE_ENV === 'production';
...@@ -26,6 +27,9 @@ var SCRIPT_DIR = 'build/scripts'; ...@@ -26,6 +27,9 @@ var SCRIPT_DIR = 'build/scripts';
var STYLE_DIR = 'build/styles'; var STYLE_DIR = 'build/styles';
var FONTS_DIR = 'build/fonts'; var FONTS_DIR = 'build/fonts';
var IMAGES_DIR = 'build/images'; var IMAGES_DIR = 'build/images';
var TEMPLATES_DIR = 'h/templates/client';
var liveReloadServer;
function parseCommandLine() { function parseCommandLine() {
commander commander
...@@ -201,17 +205,45 @@ gulp.task('watch-images', function () { ...@@ -201,17 +205,45 @@ gulp.task('watch-images', function () {
gulp.watch(imageFiles, ['build-images']); gulp.watch(imageFiles, ['build-images']);
}); });
gulp.task('watch-templates', function () {
gulp.watch(TEMPLATES_DIR + '/*.html', function (file) {
liveReloadServer.notifyChanged([file.path]);
});
});
var MANIFEST_SOURCE_FILES = 'build/@(fonts|images|scripts|styles)/*.@(js|css|woff|jpg|png|svg)'; var MANIFEST_SOURCE_FILES = 'build/@(fonts|images|scripts|styles)/*.@(js|css|woff|jpg|png|svg)';
// Generate a JSON manifest mapping file paths to var prevManifest = {};
// URLs containing cache-busting query string parameters
/**
* Return an array of asset paths that changed between
* two versions of a manifest.
*/
function changedAssets(prevManifest, newManifest) {
return Object.keys(newManifest).filter(function (asset) {
return newManifest[asset] !== prevManifest[asset];
});
}
/**
* Generate a JSON manifest mapping file paths to
* URLs containing cache-busting query string parameters.
*/
function generateManifest() { function generateManifest() {
var stream = gulp.src(MANIFEST_SOURCE_FILES) gulp.src(MANIFEST_SOURCE_FILES)
.pipe(manifest({name: 'manifest.json'})) .pipe(manifest({name: 'manifest.json'}))
.pipe(gulp.dest('build/')); .pipe(through.obj(function (file, enc, callback) {
stream.on('end', function () {
gulpUtil.log('Updated asset manifest'); gulpUtil.log('Updated asset manifest');
}); if (liveReloadServer) {
var newManifest = JSON.parse(file.contents.toString());
var changed = changedAssets(prevManifest, newManifest);
prevManifest = newManifest;
liveReloadServer.notifyChanged(changed);
}
this.push(file);
callback();
}))
.pipe(gulp.dest('build/'));
} }
gulp.task('watch-manifest', function () { gulp.task('watch-manifest', function () {
...@@ -222,6 +254,11 @@ gulp.task('watch-manifest', function () { ...@@ -222,6 +254,11 @@ gulp.task('watch-manifest', function () {
})); }));
}); });
gulp.task('start-live-reload-server', function () {
var LiveReloadServer = require('./scripts/gulp/live-reload-server');
liveReloadServer = new LiveReloadServer(3000, 'http://localhost:5000');
});
gulp.task('build-app', gulp.task('build-app',
['build-app-js', ['build-app-js',
'build-css', 'build-css',
...@@ -238,12 +275,14 @@ gulp.task('build', ...@@ -238,12 +275,14 @@ gulp.task('build',
generateManifest); generateManifest);
gulp.task('watch', gulp.task('watch',
['watch-app-js', ['start-live-reload-server',
'watch-app-js',
'watch-extension-js', 'watch-extension-js',
'watch-css', 'watch-css',
'watch-fonts', 'watch-fonts',
'watch-images', 'watch-images',
'watch-manifest']); 'watch-manifest',
'watch-templates']);
function runKarma(baseConfig, opts, done) { function runKarma(baseConfig, opts, done) {
// See https://github.com/karma-runner/karma-mocha#configuration // See https://github.com/karma-runner/karma-mocha#configuration
......
...@@ -106,6 +106,12 @@ function setupHttp($http) { ...@@ -106,6 +106,12 @@ function setupHttp($http) {
$http.defaults.headers.common['X-Client-Id'] = streamer.clientId; $http.defaults.headers.common['X-Client-Id'] = streamer.clientId;
} }
function processAppOpts() {
if (settings.liveReloadServer) {
require('./live-reload-client').connect(settings.liveReloadServer);
}
}
module.exports = angular.module('h', [ module.exports = angular.module('h', [
// Angular addons which export the Angular module name // Angular addons which export the Angular module name
// via module.exports // via module.exports
...@@ -197,3 +203,5 @@ module.exports = angular.module('h', [ ...@@ -197,3 +203,5 @@ module.exports = angular.module('h', [
.run(setupCrossFrame) .run(setupCrossFrame)
.run(setupHttp); .run(setupHttp);
processAppOpts();
'use strict';
var queryString = require('query-string');
var Socket = require('./websocket');
/**
* Return a URL with a cache-busting query string parameter added.
*
* @param {string} url - The original asset URL
* @return {string} The URL with a cache-buster added.
*/
function cacheBustURL(url) {
var newUrl = url;
var cacheBuster = queryString.parse({timestamp: Date.now()});
if (url.indexOf('?') !== -1) {
newUrl += '&' + cacheBuster;
} else {
newUrl += '?' + cacheBuster;
}
return newUrl;
}
/**
* Return true if a URL matches a list of paths of modified assets.
*
* @param {string} url - The URL of the stylesheet, script or other resource.
* @param {Array<string>} changed - List of paths of modified assets.
*/
function didAssetChange(url, changed) {
return changed.some(function (path) {
return url.indexOf(path) !== -1;
});
}
/**
* Reload a stylesheet or media element if it references a file
* in a list of changed assets.
*
* @param {Element} element - An HTML <link> tag or media element.
* @param {Array<string>} changed - List of paths of modified assets.
*/
function maybeReloadElement(element, changed) {
var parentElement = element.parentNode;
var newElement = element.cloneNode();
var srcKeys = ['href', 'src'];
srcKeys.forEach(function (key) {
if (key in element && didAssetChange(element[key], changed)) {
newElement[key] = cacheBustURL(element[key]);
}
});
parentElement.replaceChild(newElement, element);
}
function reloadExternalStyleSheets(changed) {
var linkTags = [].slice.apply(document.querySelectorAll('link'));
linkTags.forEach(function (tag) {
maybeReloadElement(tag, changed);
});
}
/**
* Connect to the live-reload server at @p url.
*
* @param {string} url - The URL of the live reload server. If undefined,
* the 'livereloadserver' query string parameter is
* used.
*/
function connect(url) {
var conn = new Socket(url);
conn.on('open', function () {
console.log('Live reload client listening');
});
conn.on('message', function (event) {
var message = JSON.parse(event.data);
if (message.type === 'assets-changed') {
var scriptsOrTemplatesChanged = message.changed.some(function (path) {
return path.match(/\.(html|js)$/);
});
var stylesChanged = message.changed.some(function (path) {
return path.match(/\.css$/);
});
if (scriptsOrTemplatesChanged) {
// Ask the host page to reload the client (eg. by reloading itself).
window.top.postMessage({type:'reloadrequest'}, '*');
return;
}
if (stylesChanged) {
reloadExternalStyleSheets(message.changed);
}
}
});
conn.on('error', function (err) {
console.error('Error connecting to live reload server:', err);
});
}
module.exports = {
connect: connect,
};
...@@ -83,7 +83,8 @@ ...@@ -83,7 +83,8 @@
"proxyquire-universal": "^1.0.8", "proxyquire-universal": "^1.0.8",
"proxyquireify": "^3.0.0", "proxyquireify": "^3.0.0",
"request": "^2.69.0", "request": "^2.69.0",
"sinon": "1.16.1" "sinon": "1.16.1",
"websocket": "^1.0.22"
}, },
"engines": { "engines": {
"node": "0.10.x" "node": "0.10.x"
......
'use strict';
var fs = require('fs');
var gulpUtil = require('gulp-util');
var http = require('http');
var WebSocketServer = require('websocket').server;
function changelogText() {
return fs.readFileSync('./CHANGES', 'utf-8');
}
/**
* An HTTP and WebSocket server which enables live reloading of the client.
*
* A simple HTTP and WebSocket server
* which serves a test page with the Hypothesis client embedded
* and notifies connected clients when assets are modified, enabling
* the client to live-reload.
*
* @param {number} port - The port that the test server should listen on.
* @param {string} appServer - The URL of the Hypothesis service to load
* the client from.
*
* @constructor
*/
function LiveReloadServer(port, appServer) {
var connections = [];
function listen() {
var log = gulpUtil.log;
var server = http.createServer(function (req, response) {
var content = `
<html>
<head>
<meta charset="UTF-8">
<title>Hypothesis Client Test</title>
</head>
<body>
<pre style="margin: 75px;">${changelogText()}</pre>
<script>
window.hypothesisConfig = function () {
return {
liveReloadServer: 'ws://localhost:${port}',
// Open the sidebar when the page loads
firstRun: true,
};
};
window.addEventListener('message', function (event) {
if (event.data.type && event.data.type === 'reloadrequest') {
window.location.reload();
}
});
</script>
<script src="${appServer}/embed.js"></script>
</body>
</html>
`;
response.end(content);
});
server.listen(port, function (err) {
if (err) {
log('Setting up live reload server failed', err);
}
log(`Live reload server listening at http://localhost:${port}/`);
});
var ws = new WebSocketServer({
httpServer: server,
});
ws.on('request', function (req) {
log('Live reload client connected');
var conn = req.accept(null, req.origin);
connections.push(conn);
conn.on('close', function () {
var closedConn = conn;
connections = connections.filter(function (conn) {
return conn !== closedConn;
});
});
});
}
/**
* Notify connected clients about assets that changed.
*
* @param {Array<string>} assets - A list of paths of assets that changed.
* Paths are relative to the root asset
* build directory.
*/
this.notifyChanged = function (assets) {
connections.forEach(function (conn) {
conn.sendUTF(JSON.stringify({
type: 'assets-changed',
changed: assets,
}));
});
};
listen();
}
module.exports = LiveReloadServer;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment