@adobe/helix-cli
Version:
Project Helix CLI
277 lines (242 loc) • 6.82 kB
JavaScript
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// eslint-disable-next-line max-classes-per-file
import fs from 'fs';
import chokidar from 'chokidar';
import WebSocket from 'faye-websocket';
import { EventEmitter } from 'events';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
/**
* Client connection for the live reload server.
*/
class ClientConnection extends EventEmitter {
static nextId() {
ClientConnection.counter = (ClientConnection.counter || 0) + 1;
return `ws${ClientConnection.counter}`;
}
constructor(req, socket, head) {
super();
this.id = ClientConnection.nextId();
this.ws = new WebSocket(req, socket, head);
this.ws.onmessage = this._onMessage.bind(this);
this.ws.onclose = this._onClose.bind(this);
}
_onMessage(event) {
let data = {};
try {
data = JSON.parse(event.data);
} catch {
// ignore
}
switch (data.command) {
case 'hello':
return this._cmdHello(data);
case 'info':
return this._cmdInfo(data);
default:
return {};
}
}
_onClose(event) {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.emit('end', event);
}
_cmdHello() {
this._send({
command: 'hello',
protocols: [
'http://livereload.com/protocols/official-7',
],
serverName: 'franklin-simulator',
});
}
_cmdInfo(data) {
if (data) {
this.plugins = data.plugins;
this.url = data.url;
}
return { ...data || {}, id: this.id, url: this.url };
}
_send(data) {
if (this.ws) {
this.ws.send(JSON.stringify(data));
}
}
sendReload(files) {
files.forEach((file) => {
this._send({
command: 'reload',
path: file,
liveCSS: true,
reloadMissingCSS: true,
liveImg: true,
});
}, this);
}
sendAlert(message) {
this._send({
command: 'alert',
message,
});
}
close() {
this._onClose({});
}
}
/**
* Live reload file watcher and server.
*/
export default class LiveReload extends EventEmitter {
constructor(logger) {
super();
// file to request mapping
this._fileMapping = new Map();
// pending requests by request id
this._pending = new Map();
this._logger = logger;
// client connections
this._connections = {};
this._liveReloadJSPath = require.resolve('livereload-js/dist/livereload.js');
}
get log() {
return this._logger;
}
startRequest(requestId, pathname) {
this._pending.set(requestId, {
pathname,
files: [],
});
}
endRequest(requestId) {
const info = this._pending.get(requestId);
if (!info) {
this.log.debug('unable to register accessed files. info does not exist: ', requestId);
return;
}
this._pending.delete(requestId);
this.registerFiles(info.files, info.pathname);
}
registerFiles(files, pathName) {
this._watcher.add(files);
files.forEach((file) => {
this._fileMapping.set(file, pathName);
});
}
registerFile(requestId, filePath) {
const info = this._pending.get(requestId);
if (info) {
info.files.push(filePath);
} else {
this.log.debug(`unable to register file ${filePath}. info for ${requestId} does not exit.`);
}
}
async init(app, httpServer) {
this._server = httpServer;
app.get('/__internal__/livereload.js', this._serveLiveReload.bind(this));
httpServer.on('upgrade', this._onSvrUpgrade.bind(this));
httpServer.on('error', this._onSvrError.bind(this));
httpServer.on('close', this._onSvrClose.bind(this));
this._initWatcher();
}
_initWatcher() {
let timer = null;
let modifiedFiles = {};
this._watcher = chokidar.watch([], {
ignored: [/(.*\.swx|.*\.swp|.*~)/],
persistent: true,
ignoreInitial: true,
});
this._watcher.on('all', (eventType, file) => {
modifiedFiles[file] = true;
if (timer) {
clearTimeout(timer);
}
// debounce a bit in case several files are changed at once
timer = setTimeout(async () => {
timer = null;
// only proceed if watcher not closed.
if (this._watcher) {
const files = Object.keys(modifiedFiles);
modifiedFiles = {};
// inform clients
await this.changed(files);
}
}, 100);
});
}
async stop() {
if (this._watcher) {
await this._watcher.close();
delete this._watcher;
}
this._onSvrClose();
this.log.debug('livereload stopped.');
}
_onSvrUpgrade(req, socket, head) {
const cx = new ClientConnection(req, socket, head);
this._connections[cx.id] = cx;
socket.on('error', (e) => {
if (e.code === 'ECONNRESET' || e.code === 'EBADF') {
return;
}
this._onSvrError(e);
});
cx.once('end', () => {
this.log.debug(`websocket connection closed ${cx.id} (url: ${cx.url})`);
delete this._connections[cx.id];
});
}
_onSvrClose() {
Object.values(this._connections).forEach((cx) => {
try {
cx.close();
} catch (e) {
this.log.error('error closing connection', e);
}
}, this);
}
_onSvrError(e) {
this.log.error(e);
}
_serveLiveReload(req, res) {
res.setHeader('content-type', 'application/javascript');
fs.createReadStream(this._liveReloadJSPath).pipe(res);
}
async changed(files) {
this.log.debug(`changed files: ${files}`);
// map each file to the registered source
const sources = new Set();
files.forEach((file) => {
const mapping = this._fileMapping.get(file);
if (mapping) {
sources.add(mapping);
}
});
const modified = Array.from(sources);
await this.emit('modified', modified);
Object.values(this._connections).forEach((cx) => {
this.log.debug(`reloading client ${cx.id} (url: ${cx.url})`);
cx.sendReload(modified);
});
}
alert(message) {
this.log.debug(`alert: ${message}`);
Object.values(this._connections).forEach((cx) => {
this.log.debug(`alert client ${cx.id} (url: ${cx.url})`);
cx.sendAlert(message);
});
}
}