@mjcctech/meteor-desktop
Version:
Build a Meteor's desktop client with hot code push.
596 lines (526 loc) • 19.6 kB
JavaScript
import http from 'http';
import connect from 'connect';
import findPort from 'find-port';
import enableDestroy from 'server-destroy';
import url from 'url';
import path from 'path';
import fs from 'fs-plus';
import send from 'send';
import mime from 'mime';
const oneYearInSeconds = 60 * 60 * 24 * 365;
// This is a stream protocol response object that is compatible in very limited way with
// node's http.ServerResponse.
// https://github.com/electron/electron/blob/master/docs/api/structures/stream-protocol-response.md
class StreamProtocolResponse {
constructor() {
this._headers = {}; // eslint-disable-line
this.statusCode = 200;
this.data = null;
this.headers = {};
}
setHeader(key, value) {
this._headers[key] = value; // eslint-disable-line
}
setStream(stream) {
this.data = stream;
}
setStatusCode(code) {
this.statusCode = code;
}
finalize() {
this.headers = this._headers; // eslint-disable-line
}
}
function* iterate(array) {
for (const entry of array) { // eslint-disable-line
yield entry;
}
}
/**
* Creates stream protocol response for a given file acting like it would come
* from a real HTTP server.
*
* @param {string} filePath - path to the file being sent
* @param {StreamProtocolResponse} res - the response object
* @param {Function} beforeFinalize - function to be run before finalizing the response object
*/
function createStreamProtocolResponse(filePath, res, beforeFinalize) {
if (!fs.existsSync(filePath)) {
return;
}
// Setting file size.
const stat = fs.statSync(filePath);
res.setHeader('Content-Length', stat.size);
// Setting last modified date.
const modified = stat.mtime.toUTCString();
res.setHeader('Last-Modified', modified);
// Determining mime type.
const type = mime.getType(filePath);
if (type) {
const charset = mime.getExtension(type);
res.setHeader('Content-Type', type + (charset ? `; charset=${charset}` : ''));
} else {
res.setHeader('Content-Type', 'application/octet-stream');
}
res.setHeader('Connection', 'close');
res.setStream(fs.createReadStream(filePath));
res.setStatusCode(200);
beforeFinalize();
res.finalize();
}
/**
* Simple local HTTP server tailored for meteor app bundle.
* Additionally it supports a local mode that creates a StreamProtocolResponse objects
* for a given path.
*
* @param {Object} log - Logger instance
* @param {Object} settings
* @param {Object} skeletonApp
*
* @property {Array} errors
* @constructor
*/
export default class LocalServer {
constructor({ log, settings = { localFilesystem: false, allowOriginLocalServer: false }, skeletonApp }) {
this.log = log;
this.httpServerInstance = null;
this.server = null;
this.retries = 0;
this.maxRetries = 3;
this.serverPath = '';
this.parentServerPath = '';
this.portRange = [57200, 57400];
this.portSearchStep = 20;
this.assetBundle = null;
this.errors = [];
this.errors[0] = 'Could not find free port.';
this.errors[1] = 'Could not start http server.';
this.localFilesystemUrl = '/local-filesystem/';
this.desktopAssetsUrl = '/___desktop/';
this.settings = settings;
this.portFilePath = path.join(skeletonApp.userDataDir, 'port.cfg');
this.lastUsedPort = this.loadPort();
this.handlers = [];
}
/**
* Registers a handler for the local mode.
* @param {Function} handler
*/
use(handler) {
this.handlers.push(handler);
}
/**
* Returns a HTTP 500 response.
* @returns {StreamProtocolResponse}
*/
static getServerErrorResponse() {
const res = new StreamProtocolResponse();
res.setStatusCode(500);
return res;
}
/**
* Processes a request url and responds with StreamProtocolResponse
* @param {string} requestUrl
* @returns {Promise<any>}
*/
getStreamProtocolResponse(requestUrl) {
const res = new StreamProtocolResponse();
const req = { url: requestUrl };
const it = iterate(this.handlers);
const next = () => {
const handler = it.next();
if (handler.done) {
res.setStatusCode(404); // ma a handler only for local streams
} else {
handler.value(
req,
res,
next,
true
);
}
};
return new Promise((resolve, reject) => {
try {
next();
resolve(res);
} catch (e) {
reject(e);
}
});
}
/**
* Sets refs for the callbacks.
*
* @param {function} onStartupFailed
* @param {function} onServerReady
* @param {function} onServerRestarted
*/
setCallbacks(onStartupFailed, onServerReady, onServerRestarted) {
this.onStartupFailed = onStartupFailed;
this.onServerReady = onServerReady;
this.onServerRestarted = onServerRestarted;
}
/**
* Initializes the module. Configures `connect` and searches for free port.
*
* @param {AssetBundle} assetBundle - asset bundle from the autoupdate
* @param {string} desktopPath - path to desktop.asar
* @param {boolean} restart - are we restarting the server?
*/
init(assetBundle, desktopPath, restart) {
const self = this;
/**
* Responds with HTTP status code and a message.
*
* @param {Object} res - response object
* @param {number} code - http response code
* @param {string} message - message
*/
function respondWithCode(res, code, message) {
/* eslint-disable */
res._headers = {};
res._headerNames = {};
res.statusCode = code;
/* eslint-enable */
res.setHeader('Content-Type', 'text/plain; charset=UTF-8');
res.setHeader('Content-Length', Buffer.byteLength(message));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.end(message);
}
/**
* If there is a path for a source map - adds a X-SourceMap header pointing to it.
*
* @param {Asset} asset - currently sent asset
* @param {Object} res - response object
*/
function addSourceMapHeader(asset, res) {
if (asset.sourceMapUrlPath) {
res.setHeader('X-SourceMap', asset.sourceMapUrlPath);
}
}
/**
* If there is a hash, adds an ETag header with it.
*
* @param {Asset} asset - currently sent asset
* @param {Object} res - response object
*/
function addETagHeader(asset, res) {
if (asset.hash) {
res.setHeader('ETag', asset.hash);
}
}
/**
* If the manifest defines the file as cacheable and query has a cache buster (i.e.
* hash added to it after ?) adds a Cache-Control header letting know Chrome that this
* file can be cached. If that is not the case, no-cache is passed.
*
* @param {Asset} asset - currently sent asset
* @param {Object} res - response object
* @param {string} fullUrl - url
*/
function addCacheHeader(asset, res, fullUrl) {
const shouldCache = asset.cacheable && (/[0-9a-z]{40}/).test(fullUrl);
res.setHeader('Cache-Control', shouldCache ? `max-age=${oneYearInSeconds}` : 'no-cache');
}
/**
* Provides assets defined in the manifest.
*
* @param {Object} req - request object
* @param {Object} res - response object
* @param {Function} next - called on handler miss
* @param {boolean} local - local mode
*/
function AssetHandler(req, res, next, local = false) {
const parsedUrl = url.parse(req.url);
// Check if we have an asset for that url defined.
/** @type {Asset} */
const asset = self.assetBundle.assetForUrlPath(parsedUrl.pathname);
if (!asset) return next();
const processors = () => (
addSourceMapHeader(asset, res),
addETagHeader(asset, res),
addCacheHeader(asset, res, req.url)
);
if (local) {
return createStreamProtocolResponse(asset.getFile(), res, processors);
}
return send(
req,
encodeURIComponent(asset.getFile()),
{ etag: false, cacheControl: false }
)
.on('file', processors)
.pipe(res);
}
/**
* Right now this is only used to serve cordova.js and it might seems like an overkill but
* it will be used later for serving desktop specific files bundled into meteor bundle.
*
* @param {Object} req - request object
* @param {Object} res - response object
* @param {Function} next - called on handler miss
* @param {boolean} local - local mode
*/
function WwwHandler(req, res, next, local = false) {
const parsedUrl = url.parse(req.url);
if (parsedUrl.pathname !== '/cordova.js') {
return next();
}
const parentAssetBundle = self.assetBundle.getParentAssetBundle();
// We need to obtain a path for the initial asset bundle which usually is the parent
// asset bundle, but if there were not HCPs yet, the main asset bundle is the
// initial one.
const initialAssetBundlePath =
parentAssetBundle ?
parentAssetBundle.getDirectoryUri() : self.assetBundle.getDirectoryUri();
const filePath = path.join(initialAssetBundlePath, parsedUrl.pathname);
if (fs.existsSync(filePath)) {
return local ?
createStreamProtocolResponse(filePath, res, () => {}) :
send(req, encodeURIComponent(filePath)).pipe(res);
}
return next();
}
/**
* Provides files from the filesystem on a specified url alias.
*
* @param {Object} req - request object
* @param {Object} res - response object
* @param {Function} next - called on handler miss
* @param {string} urlAlias - url alias on which to serve the files
* @param {string=} localPath - serve files only from this path
* @param {boolean} local - local mode
*/
function FilesystemHandler(req, res, next, urlAlias, localPath, local = false) {
const parsedUrl = url.parse(req.url);
if (!parsedUrl.pathname.startsWith(urlAlias)) {
return next();
}
if (self.settings.allowOriginLocalServer) {
res.setHeader('Access-Control-Allow-Origin', '*');
}
const bareUrl = parsedUrl.pathname.substr(urlAlias.length);
let filePath;
if (localPath) {
filePath = path.join(localPath, decodeURIComponent(bareUrl));
if (filePath.toLowerCase().lastIndexOf(localPath.toLowerCase(), 0) !== 0) {
return respondWithCode(res, 400, 'Wrong path.');
}
} else {
filePath = decodeURIComponent(bareUrl);
}
if (fs.existsSync(filePath)) {
return local ?
createStreamProtocolResponse(filePath, res, () => {}) :
send(req, encodeURIComponent(filePath)).pipe(res);
}
return local ? res.setStatusCode(404) : respondWithCode(res, 404, 'File does not exist.');
}
/**
* Serves files from the entire filesystem if enabled in settings.
*
* @param {Object} req - request object
* @param {Object} res - response object
* @param {Function} next - called on handler miss
* @param {boolean} local - local mode
*/
function LocalFilesystemHandler(req, res, next, local = false) {
if (!self.settings.localFilesystem) {
return next();
}
return FilesystemHandler(req, res, next, self.localFilesystemUrl, undefined, local);
}
/**
* Serves files from the assets directory.
*
* @param {Object} req - request object
* @param {Object} res - response object
* @param {Function} next - called on handler miss
* @param {boolean} local - local mode
*/
function DesktopAssetsHandler(req, res, next, local = false) {
return FilesystemHandler(req, res, next, self.desktopAssetsUrl, path.join(desktopPath, 'assets'), local);
}
/**
* Serves index.html as the last resort.
*
* @param {Object} req - request object
* @param {Object} res - response object
* @param {Function} next - called on handler miss
* @param {boolean} local - local mode
*/
function IndexHandler(req, res, next, local = false) {
const parsedUrl = url.parse(req.url);
if (!parsedUrl.pathname.startsWith(self.localFilesystemUrl) &&
parsedUrl.pathname !== '/favicon.ico'
) {
/** @type {Asset} */
const indexFile = self.assetBundle.getIndexFile();
if (local) {
createStreamProtocolResponse(indexFile.getFile(), res, () => {
});
} else {
send(req, encodeURIComponent(indexFile.getFile())).pipe(res);
}
} else {
next();
}
}
if (this.assetBundle === null) {
// `connect` will do the job!
const server = connect();
if (restart) {
if (this.httpServerInstance) {
this.httpServerInstance.destroy();
}
}
this.log.info('will serve from: ', assetBundle.getDirectoryUri());
server.use(AssetHandler);
server.use(WwwHandler);
server.use(LocalFilesystemHandler);
server.use(DesktopAssetsHandler);
server.use(IndexHandler);
this.use(AssetHandler);
this.use(WwwHandler);
this.use(LocalFilesystemHandler);
this.use(DesktopAssetsHandler);
this.use(IndexHandler);
this.server = server;
}
this.assetBundle = assetBundle;
this.findPort()
.then(() => {
this.startHttpServer(restart);
})
.catch(() => {
this.log.error('could not find free port');
this.onStartupFailed(0);
});
}
/**
* Checks for a free port in a given port range.
* @param {number} startPort - port range start
* @param {number} stopPort - port range end
* @returns {Promise}
*/
static findFreePortInRange(startPort, stopPort) {
return new Promise((resolve, reject) => {
findPort(
'127.0.0.1',
startPort,
stopPort,
(ports) => {
if (ports.length === 0) {
reject();
} else {
const port = ports[Math.floor(Math.random() * (ports.length - 1))];
resolve(port);
}
}
);
});
}
/**
* Looks for a free port to reserve for the local server.
* @returns {Promise}
*/
findPort() {
const self = this;
let startPort;
let endPort;
if (this.lastUsedPort !== null) {
startPort = this.lastUsedPort;
endPort = this.lastUsedPort;
} else {
([startPort] = this.portRange);
endPort = this.portRange[0] + this.portSearchStep;
}
return new Promise((resolve, reject) => {
function success(port) {
self.port = port;
self.log.info(`assigned port ${self.port}`);
resolve();
}
function fail() {
if (startPort === self.lastUsedPort && endPort === startPort) {
([startPort] = self.portRange);
endPort = self.portRange[0] + self.portSearchStep;
} else {
startPort += self.portSearchStep;
endPort += self.portSearchStep;
}
if (startPort === self.portRange[1]) {
reject();
} else {
find(); // eslint-disable-line no-use-before-define
}
}
function find() {
LocalServer.findFreePortInRange(startPort, endPort)
.then(success)
.catch(fail);
}
find();
});
}
/**
* Loads the last used port number.
* @returns {null|number}
*/
loadPort() {
let port = null;
try {
port = parseInt(fs.readFileSync(this.portFilePath, this.port), 10);
} catch (e) {
// No harm in that.
}
if (port < this.portRange[0] && port > this.portRange[1]) {
return null;
}
this.log.info(`last used port is ${port}`);
return port;
}
/**
* Save the currently used port so that it will be reused on the next start.
*/
savePort() {
try {
fs.writeFileSync(this.portFilePath, this.port);
} catch (e) {
// No harm in that.
}
}
/**
* Tries to start the http server.
* @param {boolean} restart - is this restart
*/
startHttpServer(restart) {
try {
this.httpServerInstance = http.createServer(this.server);
this.httpServerInstance.on('error', (e) => {
this.log.error(e);
this.retries += 1;
if (this.retries < this.maxRetries) {
this.init(this.serverPath, this.parentServerPath, true);
} else {
this.onStartupFailed(1);
}
});
this.httpServerInstance.on('listening', () => {
this.retries = 0;
this.savePort();
if (restart) {
this.onServerRestarted(this.port);
} else {
this.onServerReady(this.port);
}
});
this.httpServerInstance.listen(this.port, '127.0.0.1');
enableDestroy(this.httpServerInstance);
} catch (e) {
this.log.error(e);
this.onStartupFailed(1);
}
}
}