diff --git a/.travis.yml b/.travis.yml index 3223ad4..8ca5323 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: - objective-c env: matrix: - - NODE_VERSION="4.1.1" + - NODE_VERSION="6" global: - secure: BuCxvkJOa8k0NzsoiFDlJQjzj5+xcbnaA4+A8maNJDAe3etcBSZBA+4nxm18jABeuE9NNg5k2rayrGKhF6kqpy0TcgvtUOx38of0FP7zaO34kKwbPuKymvoLVJ58caQsQCbBGc/UTdBxHLTe8WN2G0wkRFmWg5YKNqg5rgyrNB5e8V1bWfILg/PRX5jla/59sG5+A/2vGa6HRpiEdRk0QvxUIJoHrh0DmkaOkQ3FK5wKd1vvfPfqQVLF6xiDjZ+C2203eqjcpapNz/6yX/C7X4XtSgNHcyOnWpcY2A9m+CYINzPfoAPnZJpdc5vWfDyR6rQJ/3PcG3Ol8M2eDwH2FjhaPpNwotrAHZUNod7WCaRaWVfb8Qku4fnO0LpQQ+eBIKbHCR4VeNWUo7EX7T3445oxxft7nwszaLpL1DYKFgoMAAbTclycJFUK4cZwYCSxOYKfdYKi9R5889TEw32FRCLrOjW3Pglqo2ljPsGk/pG8MmRzA2sI96xUFUwHuuUhmmbXd8oSCbWZjWaB3gxxVRKG26LfK8DAR6w0+7C42Z2JuNsdnQopTRxicbXCr13i5ZPOSrZrgEpwm3tHU3MCO5uXBlrpl4jwyBBqkQusJOiP1tc/5Iu008Y0cHty/Z5Juu1apaAm8NrXKkJt+jerewbIT4cSqYFzWRAfC8opQUc= - secure: LbuFSJhaqH8X84n9D754UN0dqeTbHEFU1Z4TRvtSZfG9tRrMWqvf5vOaiV/6fAqgLVo1eL5CUcEvrDrLxVLJTXs8V4UPJT+H8mUPVFlX4fOLCYDHllphUUnyDH7rPSb5QkfPAIa0rl5YtHPpMi0HwYD75L5uEwZGJEaj1rE/BqjrXROoCakIRcgi2GzUA3WiPSQPxry2AEI948Kv+jotD990UjRYQDlx+RCdS3bzHaBHvKJPKvVNWaf+g29xNZI3vMu7bZZj25BeheOmta1mMfGduSEKjxc4BukHOaN7ydyjiVEMIvqx5es3VQNr0CfJpkZAS3Y+6r2GxZ4zmUdwEKH5nJZ5RmKvXt4L3mp+fs1W5IdOCmxihJtRvt+JjZGEDdgqx33t2uv+kZOdaFHpVzD8DdQZfcfzTgLS3TFSo/7QgbAlCq88kwV+OwHoIPiW07cqHHon4pyS5IdvX8L0wEymLXna+l2TM0eELGnttd4mFu1BBgWg2HLoQKohmCztcof1bw+63Ugi2nFdsHWPfOqvQGhyXBBHMcFdWO7a1fm3JaQvD99svl6vNyI58q7byHTYVoHcMTVGSTYA4bEe2s6l/GIJTiJP5y8d9uLz9ASq6OKUJ/8OCulFbzLzN/BkJ09r7kmVGJsNkyUyuqFtLBMsR0fzUPNahLsj8+v0Ns8= diff --git a/backend.js b/backend.js index 571ca34..efadfe2 100644 --- a/backend.js +++ b/backend.js @@ -1,5 +1,5 @@ /** - * A Node.JS back-end for Remote View feature of LiveStyle app: + * A Node.JS back-end for Remote View feature of LiveStyle app: * manages connections to LiveStyle and Remote View servers and * responds to RV messages. * @@ -8,74 +8,65 @@ */ 'use strict'; -var debug = require('debug')('lsapp:backend'); -var extend = require('xtend'); -var tunnelController = require('./lib/controller/tunnel'); -var fileServerController = require('./lib/controller/file-server'); +const debug = require('debug')('lsapp:backend'); +const TunnelController = require('./lib/controller/tunnel'); +const fileServer = require('./lib/file-server'); +const pkg = require('./package.json'); + +const tunnels = new TunnelController(pkg.config); +const forwardMessages = new Set(['incoming-updates', 'diff']); module.exports = function(client) { + let sendSessionList = () => client.send('rv-session-list', tunnels.list().map(upgradeSession)); + + tunnels + .on('clusterDestroy', sendSessionList) + .on('clusterCreate', sendSessionList); + client .on('rv-ping', function() { debug('ping'); client.send('rv-pong'); }) - .on('rv-get-session', function(data) { - var origin = data && data.localSite; + .on('rv-get-session', data => { + var origin = data && (data.origin || data.localSite); debug('get session for %s', origin); client.send('rv-session', sessionPayload(origin)); }) - .on('rv-get-session-list', function() { - var sessions = tunnelController.list() - .map(function(session) { - // if this is a local server, rewrite its localSite to server docroot - var localServer = fileServerController.find(session.localSite); - if (localServer) { - session = extend(session, {localSite: localServer.rv.docroot}); - } - return session; - }); - client.send('rv-session-list', sessions); - }) - .on('rv-create-session', function(data) { + .on('rv-get-session-list', sendSessionList) + .on('rv-create-session', data => { + data = upgradePayload(data); debug('create session %o', data); - var onError = function(err) { - debug('error when creating session for %s: %s', data.localSite, err ? err.message : 'unknown'); + tunnels.connect(data) + .then(cluster => { + debug('created connection for %s', data.origin); + client.send('rv-session', sessionPayload(data.origin)); + }) + .catch(err => { + debug('error when creating session for %s: %s', data.origin, err ? err.message : 'unknown'); var message = err ? err.message : 'Unable to establish tunnel with Remote View server'; client.send('rv-session', { - localSite: data.localSite, + origin: data.origin, error: message + '. Please try again later.' }); - }; - - var onConnect = function() { - debug('created session for %s', data.localSite); - client.send('rv-session', sessionPayload(data.localSite)); - this.removeListener('destroy', onDestroy); - }; - - var onDestroy = function(err) { - err && onError(err); - this.removeListener('connect', onConnect); - }; - - tunnelController.create(data) - .once('connect', onConnect) - .once('destroy', onDestroy); + }); }) - .on('rv-close-session', function(data) { + .on('rv-close-session', data => { + data = upgradePayload(data); debug('close session %o', data); - module.exports.closeRvSession(data.localSite); + closeRvSession(data.origin); }) .on('rv-create-http-server', function(data) { - fileServerController(data.docroot) - .then(function(origin) { + debug('Explicit HTTP server creation is deprecated, use "rv-create-session" directly with file:// origin'); + fileServer(data.docroot) + .then(server => { client.send('rv-http-server', { docroot: data.docroot, - origin + origin: server.host }); }) - .catch(function(err) { + .catch(err => { client.send('rv-http-server', { docroot: data.docroot, error: err.message @@ -83,47 +74,95 @@ module.exports = function(client) { }); }); - fileServerController.forward(client); + setupMessageForwarding(client); + sendSessionList(); return client; }; -module.exports.closeRvSession = function(key) { +const closeRvSession = module.exports.closeRvSession = function(key) { debug('requested session %o close', key); var session = sessionPayload(key); if (session && !session.error) { debug('closing %s', session.publicId); - tunnelController.close(session.publicId); + tunnels.close(session.publicId); } }; +module.exports.tunnels = tunnels; + +function upgradePayload(data) { + if (data.localSite && !data.origin) { // v1.0 + data = Object.assign({}, data, {origin: data.localSite}); + } + return data; +} + function findSession(key) { - for (let session of tunnelController.list()) { - if (session.localSite === key || session.publicId === key) { + for (let session of tunnels.list()) { + if (session.origin === key || session.localSite === key || session.publicId === key) { return session; } } } -function sessionPayload(localSite) { - var session = findSession(localSite); - if (!session) { - // mayabe its a local web-server? - var localServer = fileServerController.find(localSite); - if (localServer) { - session = findSession(localServer.rv.address); - if (session) { - session = extend(session, {localSite}) - } - } +function sessionPayload(origin) { + var session = findSession(origin); + if (session && session.state !== 'destroyed') { + return upgradeSession(session); } - return session || { - localSite, + return { + origin, + localSite: origin, error: 'Session not found' }; } +function upgradeSession(session) { + return Object.assign({}, session, {state: 'connected'}); +} + +/** + * Setup LiveStyle message forwarding from pages with `file://` origin to their + * temporary file servers created for Remote View sessions + * @param {LiveStyleClient} + */ +function setupMessageForwarding(client) { + let onMessage = function(payload) { + if (typeof payload === 'string') { + payload = JSON.parse(payload); + } + + if (!payload || !payload.data || !forwardMessages.has(payload.name)) { + return debug('skip message forward: unsupported message "%s"', payload.name); + } + + // is this a filesystem? + if (!/^file:/.test(payload.data.uri)) { + return debug('skip message forward: "%s" is not a file origin', payload.data.uri); + } + + let file = fileServer.normalizePath(payload.data.uri); + let servers = fileServer.list(file); + if (!servers.length) { + return debug('skip message forward: no matching servers for "%s" uri', payload.data.uri); + } + + servers.forEach(server => { + // rebuild URL and forward message + let relative = file.slice(server.docroot.length).replace(/[\\\/]/g, '/'); + let uri = server.host + '/' + relative; + + debug('forward message to %s', uri); + client.send(payload.name, Object.assign({}, payload.data, {uri})); + }); + }; + + client.on('message', onMessage); + return () => client.removeListener('message', onMessage); +} + if (require.main === module) { let pkg = require('./package.json'); require('./lib/client')(pkg.config.websocketUrl, function(err, client) { @@ -134,4 +173,4 @@ if (require.main === module) { module.exports(client); console.log('RV client connected'); }); -} \ No newline at end of file +} diff --git a/lib/client.js b/lib/client.js index 8bb397e..ad12a06 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,15 +1,14 @@ /** - * A minimal LiveStyle server client. Unlike existing + * A minimal LiveStyle server client. Unlike existing * `livestyle/client`, this one will not reconnect when connection * in dropped. Instead, it will start its own WeSocket server instance. */ 'use strict'; -var WebSocket = require('ws'); -var parseUrl = require('url').parse; -var debug = require('debug')('lsapp:client'); -var extend = require('xtend'); -var createServer = require('./server'); +const WebSocket = require('ws'); +const parseUrl = require('url').parse; +const debug = require('debug')('lsapp:client'); +const createServer = require('./server'); var errCount = 0; var defaultOptions = { @@ -24,11 +23,11 @@ var connect = module.exports = function(url, options, callback) { } callback = callback || noop; - options = extend(defaultOptions, options || {}); + options = Object.assign({}, defaultOptions, options || {}); debug('connecting to %s', url); var client = new WebSocket(url); - + return client .on('message', onMessage) .once('open', function() { @@ -98,4 +97,4 @@ function wrapClient(client) { } }; return client; -} \ No newline at end of file +} diff --git a/lib/controller/app-model.js b/lib/controller/app-model.js index bc8a931..dda65c9 100644 --- a/lib/controller/app-model.js +++ b/lib/controller/app-model.js @@ -4,14 +4,12 @@ */ 'use strict'; -var debug = require('debug')('lsapp:app-model'); -var tunnelController = require('./tunnel'); -var appsDfn = require('../apps'); -var googleChrome = require('../google-chrome'); -var sublimeText = require('../sublime-text'); +const debug = require('debug')('lsapp:app-model'); +const appsDfn = require('../apps'); +const googleChrome = require('../google-chrome'); +const sublimeText = require('../sublime-text'); module.exports = function(model, client) { - tunnelController.on('update', sessions => model.set('rvSessions', sessions)); model.on('change', () => client.send('app-model', model.toJSON())); var apps = { @@ -24,12 +22,12 @@ module.exports = function(model, client) { return { install(id) { - return apps[id] + return apps[id] ? apps[id].install() : Promise.reject(new Error(`Unknown app ${id}`)); }, detect(id) { - return apps[id] + return apps[id] ? apps[id].detect() : Promise.reject(new Error(`Unknown app ${id}`)); } @@ -40,7 +38,7 @@ function setupApp(app, handler, model, client) { var attributeName = app.id; var installPromise = null; var autoupdater; - + if (handler.autoupdate) { autoupdater = handler.autoupdate(app) .on('shouldUpdate', app => install('updating')) @@ -120,4 +118,4 @@ if (require.main === module) { } module.exports(client).on('change', () => console.log(this.attributes)); }); -} \ No newline at end of file +} diff --git a/lib/controller/file-server.js b/lib/controller/file-server.js deleted file mode 100644 index 7f927bd..0000000 --- a/lib/controller/file-server.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Local file web-server controller: wraps given file origin with local HTTP - * server (if possible) and manages server lifecycle. - */ -'use strict'; - -var path = require('path'); -var extend = require('xtend'); -var debug = require('debug')('lsapp:file-server'); -var tunnel = require('./tunnel'); -var fileServer = require('../file-server'); - -var reIsFile = /^file:/; -var servers = []; -var allowedMessages = ['incoming-updates', 'diff']; - -module.exports = function(docroot) { - docroot = normalize(docroot); - - // maybe there’s already a web-server for current docroot? - var server = find(docroot); - if (server) { - debug('local file server already exists for %s', docroot); - return Promise.resolve(server.rv.address); - } - - debug('should create local file server'); - return fileServer(docroot).then(function(server) { - var addr = server.address(); - var localSite = `http://${addr.address}:${addr.port}`; - server.rv = {address: localSite, docroot}; - servers.push(server); - - debug('created local file server %s for %s', localSite, docroot); - return localSite; - }); -}; - -module.exports.forward = function(client) { - // forward updates from filesystem origin to local HTTP server - client.on('message', function(payload) { - if (typeof payload === 'string') { - payload = JSON.parse(payload); - } - - if (!payload || !payload.data || allowedMessages.indexOf(payload.name) === -1) { - return debug('skip message forward: unsupported message "%s"', payload.name); - } - - // is this a filesystem? - if (!reIsFile.test(payload.data.uri)) { - return debug('skip message forward: "%s" is not a file origin', payload.data.uri); - } - - var file = normalize(payload.data.uri); - // find server(s) that match given origin and forward messages - servers.forEach(function(server) { - if (file.indexOf(server.rv.docroot) !== 0) { - return; - } - - // local server found, rebuild URL and forward message - var uri = server.rv.address + '/' + file - .slice(server.rv.docroot.length) - .split(/[\\\/]/g) - .filter(Boolean) - .join('/'); - - debug('forward message to %s', uri); - client.send(payload.name, extend(payload.data, {uri})); - }); - }); -}; - -var find = module.exports.find = function(key) { - var docroot = normalize(key); - for (let server of servers) { - if (server.rv.address === key || server.rv.docroot === docroot) { - return server; - } - } -}; - -var normalize = module.exports.normalize = function(dir) { - return path.normalize(dir.replace(/^file:\/\//, '')); -}; - -// destroy local web-server when session is closed -tunnel.on('clusterDestroy', function(cluster) { - var server = find(cluster.options.localSite); - // make sure no other session uses current server - var cmp = function(c) {return c.localSite === server.rv.address;}; - if (server && !tunnel.list().some(cmp)) { - server.close(); - servers.splice(servers.indexOf(server), 1); - } -}); \ No newline at end of file diff --git a/lib/controller/tunnel.js b/lib/controller/tunnel.js index 067f86f..20b2424 100644 --- a/lib/controller/tunnel.js +++ b/lib/controller/tunnel.js @@ -4,23 +4,31 @@ */ 'use strict'; -var EventEmitter = require('events'); -var debug = require('debug')('lsapp:tunnel'); -var TunnelCluster = require('remote-view-client').TunnelCluster; -var utils = require('../utils'); +const assert = require('assert'); +const EventEmitter = require('events'); +const debug = require('debug')('lsapp:tunnel'); +const TunnelCluster = require('remote-view-client').TunnelCluster; +const fileServer = require('../file-server'); +const throttle = require('../utils').throttle; +const request = require('../request'); -class TunnelClusterController extends EventEmitter { - constructor() { +module.exports = class TunnelClusterController extends EventEmitter { + constructor(options) { super(); - this.clusters = []; + + assert(options.remoteSessionUrl, '"remoteSessionUrl" option is not provided'); + + this.options = options; + this.clusters = new Set(); var self = this; this._onClusterDestroy = function(err) { debug('cluster destroyed with error %o', err); - utils.removeFromArray(self.clusters, this); + self.clusters.delete(this); this.removeListener('error', self._onClusterError); this.removeListener('state', self._onClusterStateChange); self.emit('clusterDestroy', this, err); + fileServer.destroy(this.options.origin || this.options.localSite); self._emitUpdate(); }; @@ -34,9 +42,7 @@ class TunnelClusterController extends EventEmitter { self.emit('clusterError', this, err); }; - this._emitUpdate = utils.throttle(function() { - self.emit('update', self.list()); - }, 20, {leading: false}); + this._emitUpdate = throttle(() => this.emit('update', this.list()), 20, {leading: false}); } /** @@ -44,7 +50,51 @@ class TunnelClusterController extends EventEmitter { * @return {Object} */ list() { - return this.clusters.map(clusterJSON); + return Array.from(this.clusters).map(clusterJSON); + } + + /** + * Creates Remote View connection and tunnel cluster for given payload + * @param {Object} payload + * @return {Promise} + */ + connect(payload) { + payload = payload || {}; + if (!payload.origin) { + return error('ERVASSERT', 'No "origin" field in session payload'); + } + + return createHTTPServerIfRequired(payload.origin) + .then(localSite => this.createRemoteSession(Object.assign({}, payload, {localSite}))) + .then(data => this.create(data)); + } + + /** + * Creates Remote View session with remote endpoint for given payload + * @param {Object} + * @return {Promise} + */ + createRemoteSession(payload) { + if (!payload.authorization) { + return error('ERVNOAUTH', 'No authorization header in payload'); + } + + return new Promise((resolve, reject) => { + return request.post(this.options.remoteSessionUrl, { + headers: { + Authorization: payload.authorization + }, + body: { + localSite: payload.localSite + } + }, (err, resp, body) => { + if (err || body.error) { + return reject(err || createError('ERVRESPONSE', body.error.message)); + } + + resolve(Object.assign({}, payload, body)); + }); + }); } /** @@ -58,7 +108,7 @@ class TunnelClusterController extends EventEmitter { .once('error', this._onClusterError) .once('destroy', this._onClusterDestroy); - this.clusters.push(cluster); + this.clusters.add(cluster); this.emit('clusterCreate', cluster); this._emitUpdate(); return cluster; @@ -82,9 +132,7 @@ class TunnelClusterController extends EventEmitter { * Closes all Remote View sessions */ closeAll() { - for (var i = this.clusters.length - 1; i >= 0; i--) { - this.close(this.clusters[i]); - } + this.clusters.forEach(cluster => this.close(cluster)); } /** @@ -93,22 +141,39 @@ class TunnelClusterController extends EventEmitter { * @return {TunnelCluster} */ getById(id) { - for (var i = 0, il = this.clusters.length; i < il; i++) { - if (this.clusters[i].options.publicId === id) { - return this.clusters[i]; + for (let cluster of this.clusters) { + if (cluster.options.publicId === id) { + return cluster; } } } }; -module.exports = new TunnelClusterController(); -module.exports.TunnelClusterController = TunnelClusterController; - function clusterJSON(cluster) { return { sessionId: cluster.options.sessionId, publicId: cluster.options.publicId, + origin: cluster.options.origin, localSite: cluster.options.localSite, state: cluster.state }; -} \ No newline at end of file +} + +function createHTTPServerIfRequired(origin) { + if (!/^file:/.test(origin)) { + return Promise.resolve(origin); + } + + debug('Create local HTTP server for %s', origin); + return fileServer(origin).then(server => server.host); +} + +function error(code, message) { + return Promise.reject(createError(code, message)); +} + +function createError(code, message) { + var err = new Error(message || code); + err.code = code; + return err; +} diff --git a/lib/file-server.js b/lib/file-server.js index 564d084..bc1325b 100644 --- a/lib/file-server.js +++ b/lib/file-server.js @@ -1,26 +1,102 @@ /** - * Creates a siple HTTP server for given folder. Used by Remote View for + * Creates a simple static HTTP server for given folder. Used by Remote View for * creating connections for `file:` origins */ 'use strict'; -var fs = require('graceful-fs'); -var path = require('path'); -var http = require('http'); -var connect = require('connect'); -var serveStatic = require('serve-static'); +const fs = require('graceful-fs'); +const path = require('path'); +const http = require('http'); +const connect = require('connect'); +const serveStatic = require('serve-static'); +const debug = require('debug')('lsapp:file-server'); + +const servers = new Map(); module.exports = function(dir) { - return check(dir).then(createServer); + return check(dir) + .then(dir => { + // maybe we already have server for this dir? + if (servers.has(dir)) { + debug('Server for %s already exists'); + return servers.get(dir); + } + + return createServer(dir) + .then(server => { + var addr = server.address(); + var host = `http://${addr.address}:${addr.port}`; + debug('Created %s server for %s', host, dir); + server.host = host; + server.docroot = dir; + server.once('close', () => servers.delete(dir)); + servers.set(dir, server); + return server; + }); + }); +}; + +/** + * Find existing server by given key (either docroot or host) + * @type {http.Server} + */ +const find = module.exports.find = function(key) { + debug('Searching server for %s', key); + key = normalize(key); + if (servers.has(key)) { + debug('Server for %s found by key', key); + return servers.get(key); + } + + for (let server of servers.values()) { + if (server.host === key || server.docroot === key) { + debug('Server for %s found by iteration', key); + return server; + } + } +}; + +/** + * Destroys server instance found by given key (host or docroot) + * @param {String} key + * @return {Promise} Resolved when server is closed + */ +module.exports.destroy = function(key) { + debug('Destroy server for %s', key); + return new Promise(resolve => { + let server = typeof key === 'string' ? find(key) : key; + server ? server.close(resolve) : resolve(); + }); +}; + +/** + * Returns list of available web servers. If `url` argument is given, + * returns list of servers which document root matches given url + * @param {String} docroot + * @return {Array} + */ +module.exports.list = function(url) { + let list = Array.from(servers.values()); + if (url) { + url = normalize(url); + debug('Find server for %s', url); + list = list.filter(server => url.indexOf(server.docroot) === 0); + } + + return list; }; +const normalize = module.exports.normalizePath = dir => + path.normalize(dir.replace(/^file:\/\//, '')); + /** * Check if given folder exists and is readable - * @param {String} dir + * @param {String} dir * @return {Promise} */ function check(dir) { - dir = path.normalize(dir.replace(/^file:\/\//, '')); + debug('Check existance of %s', dir); + dir = normalize(dir); return new Promise(function(resolve, reject) { fs.readdir(dir, err => err ? reject(err) : resolve(dir)); }); @@ -28,7 +104,7 @@ function check(dir) { /** * Creates HTTP server for given folder - * @param {String} dir + * @param {String} dir * @return {Promise} */ function createServer(dir) { @@ -39,4 +115,4 @@ function createServer(dir) { // start server on random port server.listen(0, 'localhost', () => resolve(server)); }); -} \ No newline at end of file +} diff --git a/lib/helpers/download.js b/lib/helpers/download.js index abfafec..50c65b3 100644 --- a/lib/helpers/download.js +++ b/lib/helpers/download.js @@ -1,5 +1,5 @@ /** - * Returns a Promise that downloads given resource and stores it in + * Returns a Promise that downloads given resource and stores it in * a temp folder. The Promise is resolved with path to downloaded * file in temp folder */ @@ -7,7 +7,6 @@ const http = require('http'); const https = require('https'); -const extend = require('xtend'); const temp = require('temp').track(); const debug = require('debug')('lsapp:download'); @@ -17,12 +16,12 @@ const defaultOptions = { }; var download = module.exports = function(url, options) { - options = extend(defaultOptions, options || {}); + options = Object.assign({}, defaultOptions, options); return new Promise(function(resolve, reject) { if (options.attempt >= options.maxAttempts) { return reject(error(`Failed to download ${url} in ${options.attempt} attempts`, 'EMAXATTEMPTS')); } - + debug('downloading %s, attempt %d', url, options.attempt); let transport = /^https:/.test(url) ? https : http; transport.get(url, function(res) { @@ -38,7 +37,7 @@ var download = module.exports = function(url, options) { // redirect let location = res.headers.location; if (location) { - let opt = extend(options, {attempt: options.attempt + 1}); + let opt = Object.assign({}, options, {attempt: options.attempt + 1}); return download(location, opt).then(resolve, reject); } else { return reject(error('Got redirect (' + res.statusCode + ') but no Location header', 'EINVALIDRESPONSE')); @@ -56,4 +55,4 @@ function error(message, code) { err.code = code; } return err; -} \ No newline at end of file +} diff --git a/lib/identify.js b/lib/identify.js index c3e81ea..0168752 100644 --- a/lib/identify.js +++ b/lib/identify.js @@ -3,10 +3,11 @@ * message and wait for `client-id` response with given `id` value */ 'use strict'; -var debug = require('debug')('lsapp:identify'); -var utils = require('./utils'); -var throttled = new Map(); +const debug = require('debug')('lsapp:identify'); +const utils = require('./utils'); + +const throttled = new Map(); module.exports = function identify(client, id) { return new Promise(function(resolve, reject) { @@ -32,8 +33,8 @@ module.exports = function identify(client, id) { var onClientId = function(data) { debug('received "client-id" with %s', data && data.id); if (data && data.id) { - let expected = Array.isArray(id) - ? id.indexOf(data.id) !== -1 + let expected = Array.isArray(id) + ? id.indexOf(data.id) !== -1 : id === data.id; if (expected) { cleanUp() @@ -56,4 +57,4 @@ function sendRequest(client) { }, 10, {leading: false})); } throttled.get(client)(); -} \ No newline at end of file +} diff --git a/lib/model.js b/lib/model.js index 56ad48e..d20413a 100644 --- a/lib/model.js +++ b/lib/model.js @@ -3,8 +3,7 @@ */ 'use strict'; -var EventEmitter = require('events'); -var copy = require('xtend'); +const EventEmitter = require('events'); module.exports = class Model extends EventEmitter { constructor() { @@ -122,6 +121,14 @@ module.exports = class Model extends EventEmitter { } }; +function copy() { + let result = {}; + for (var i = 0; i < arguments.length; i++) { + result = Object.assign(result, arguments[i]); + } + return result; +} + function has(obj, key) { return obj != null && hasOwnProperty.call(obj, key); } @@ -233,4 +240,4 @@ function eq(a, b, aStack, bStack) { aStack.pop(); bStack.pop(); return result; -}; \ No newline at end of file +}; diff --git a/lib/node-utils.js b/lib/node-utils.js index 5772d9f..812fc80 100644 --- a/lib/node-utils.js +++ b/lib/node-utils.js @@ -1,11 +1,13 @@ /** * Utils for Node.js part */ -var os = require('os'); -var path = require('path'); -var fs = require('graceful-fs'); -var debug = require('debug')('lsapp:node-utils'); -var winEnv = require('./win-env'); +'use strict'; + +const os = require('os'); +const path = require('path'); +const fs = require('graceful-fs'); +const debug = require('debug')('lsapp:node-utils'); +const winEnv = require('./win-env'); module.exports.expandUser = function(p) { return p.replace(/^~[\\\/]/, function() { @@ -106,4 +108,4 @@ module.exports.existsSome = function(paths) { }; next(); }); -}; \ No newline at end of file +}; diff --git a/lib/request.js b/lib/request.js new file mode 100644 index 0000000..92b5ba1 --- /dev/null +++ b/lib/request.js @@ -0,0 +1,11 @@ +'use strict'; + +const request = require('request') +const pkg = require('../package.json'); + +module.exports = request.defaults({ + json: true, + headers: { + 'user-agent': `LiveStyle app/${pkg.version}` + } +}); diff --git a/lib/server.js b/lib/server.js index 2b4f37d..4498f0b 100644 --- a/lib/server.js +++ b/lib/server.js @@ -2,10 +2,11 @@ * LiveStyle WebSocket server */ 'use strict'; -var http = require('http'); -var WebSocketServer = require('ws').Server; -var debug = require('debug')('lsapp:server'); -var utils = require('./utils'); + +const http = require('http'); +const WebSocketServer = require('ws').Server; +const debug = require('debug')('lsapp:server'); +const utils = require('./utils'); var server; var clients = []; // all connected clients @@ -158,4 +159,4 @@ function removeClient() { delete editors[id]; } } -} \ No newline at end of file +} diff --git a/main.js b/main.js index 3fcd3f3..69d52fa 100644 --- a/main.js +++ b/main.js @@ -36,12 +36,13 @@ var app = menubar({ } info('Client connected'); - + // supress 'error' event since in Node.js, in most cases it means unhandled exception client.on('error', err => console.error(err)); - var controller = appModelController(appModel, client); + const controller = appModelController(appModel, client); backend(client); + backend.tunnels.on('update', sessions => appModel.set('rvSessions', sessions)) updateMainWindow(appModel); setupAppEvents(app, controller); initialWindowDisplay(app); @@ -94,7 +95,7 @@ function updateMainWindow(model) { * Initial window display when app starts: do not quit app when window requested * it for the first time * @param {App} app - * @param {BrowserWindow} wnd + * @param {BrowserWindow} wnd */ function initialWindowDisplay(menuApp) { var handled = false; diff --git a/package.json b/package.json index fe24055..2c3c750 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "LiveStyle desktop app with Remote View", "main": "main.js", "scripts": { - "test": "./node_modules/.bin/mocha --reporter spec", + "app": "electron .", + "test": "mocha --reporter spec", "publish": "node ./tools/publish.js", "bundle": "node ./tools/distribute.js", "dev-bundle": "node ./tools/distribute.js --dev" @@ -23,10 +24,10 @@ "browser-launcher2": "^0.4.5", "connect": "^3.4.0", "debug": "^2.2.0", - "electron-debug": "^0.5.1", - "electron-squirrel-startup": "^0.1.4", + "electron-debug": "^1.0.1", + "electron-squirrel-startup": "^1.0.0", "graceful-fs": "^4.1.2", - "menubar": "^3.0.0", + "menubar": "^5.1.0", "mime": "^1.3.4", "mkdirp": "^0.5.1", "mv": "^2.1.1", @@ -38,20 +39,20 @@ "serve-static": "^1.10.0", "temp": "^0.8.3", "unzip": "^0.1.11", - "ws": "^0.8.0", - "xtend": "^4.0.0" + "ws": "^1.1.1" }, "devDependencies": { "cpy": "^3.4.0", "del": "^2.0.0", "electron-installer-squirrel-windows": "^1.2.2", - "electron-prebuilt": "~0.35.0", - "glob-all": "^3.0.1", - "mocha": "^2.2.5", - "rcedit": "^0.3.0", - "yazl": "^2.2.2" + "electron-prebuilt": "~1.3.4", + "glob-all": "^3.1.0", + "mocha": "^3.0.0", + "rcedit": "^0.7.0", + "yazl": "^2.4.1" }, "config": { - "websocketUrl": "ws://127.0.0.1:54000/livestyle" + "websocketUrl": "ws://127.0.0.1:54000/livestyle", + "remoteSessionUrl": "http://livestyle.io:9000/connect/" } } diff --git a/test/file-server.js b/test/file-server.js new file mode 100644 index 0000000..579c81e --- /dev/null +++ b/test/file-server.js @@ -0,0 +1,98 @@ +'use strict'; + +const path = require('path'); +const assert = require('assert'); +const request = require('request'); +const fileServer = require('../lib/file-server'); + +const docroot = path.resolve(__dirname, './static'); + +describe('File Server', () => { + function checkRequest(server, url) { + return new Promise((resolve, reject) => { + request(url || `${server.host}/index.html`, (err, resp, body) => { + if (!err && body.length) { + resolve(server); + } else { + reject(err || new Error('Empty response body')); + } + }); + }); + } + + it('create server for plain path', done => { + fileServer(docroot) + .then(checkRequest) + .then(server => { + assert.equal(fileServer.list().length, 1); + assert.equal(server.docroot, docroot); + assert(/^http:\/\/(localhost|127\.0\.0\.1):\d+/.test(server.host)); + + return fileServer.destroy(server); + }) + .then(() => { + // make sure server instance is destroyed + assert.equal(fileServer.list().length, 0); + done(); + }) + .catch(done); + }); + + it('create server for file:// path', done => { + fileServer('file://' + docroot) + .then(checkRequest) + .then(server => { + assert.equal(fileServer.list().length, 1); + assert.equal(server.docroot, docroot); + assert(/^http:\/\/(localhost|127\.0\.0\.1):\d+/.test(server.host)); + + return fileServer.destroy(server); + }) + .then(() => { + assert.equal(fileServer.list().length, 0); + done(); + }) + .catch(done); + }); + + it('re-use server for same docroot', done => { + fileServer(docroot) + .then(checkRequest) + .then(server => { + return fileServer(docroot) + .then(server2 => { + assert.equal(server, server2); + assert.equal(fileServer.list().length, 1); + return fileServer.destroy(server2); + }); + }) + .then(() => { + assert.equal(fileServer.list().length, 0); + done(); + }) + .catch(done); + }); + + it('find server for url', done => { + let docroot2 = path.resolve(docroot, 'inner'); + Promise.all([fileServer(docroot), fileServer(docroot2)]) + .then(servers => { + let [s1, s2] = servers; + assert.equal(fileServer.list().length, 2); + + let matched = fileServer.list(`file://${s1.docroot}/foo/bar/`); + assert.equal(matched.length, 1); + assert.equal(matched[0], s1); + + matched = fileServer.list(`http://livestyle.io/foo/bar/`); + assert.equal(matched.length, 0); + + return Promise.all(servers.map(s => fileServer.destroy(s))); + }) + .then(() => { + assert.equal(fileServer.list().length, 0); + done(); + }) + .catch(done); + }); +}); diff --git a/test/tunnel.js b/test/tunnel.js index d7b1a8e..8f63315 100644 --- a/test/tunnel.js +++ b/test/tunnel.js @@ -1,68 +1,151 @@ 'use strict'; -var assert = require('assert'); -var path = require('path'); -var http = require('http'); -var tc = require('../lib/controller/tunnel'); -var fileServer = require('../lib/controller/file-server'); - -// No need to check full Remote View connectivity, -// simply check that controller is properly instantiated and emits messages - -describe('Tunnel Cluster controller', function() { - it('create and emit messages', function(done) { - var clusterCreated = false; - var updates = []; - - // for some reason networking is very slow on Windows, has to increase - // test timeout - this.timeout(5000); - - tc.on('update', list => updates.push(list)) - .once('clusterCreate', cluster => clusterCreated = true) - .once('clusterDestroy', cluster => { - setTimeout(() => { - assert(clusterCreated); - assert.equal(updates.length, 2); - assert.equal(updates[0][0].publicId, 'rv-test.livestyle.io'); - assert.equal(updates[0][0].state, 'idle'); - - // the second update is empty list because cluster was destroyed - assert.deepEqual(updates[1], []); - done(); - }, 30); +const assert = require('assert'); +const http = require('http'); +const path = require('path'); +const connect = require('connect'); +const request = require('../lib/request'); +const fileServer = require('../lib/file-server'); +const TunnelClusterController = require('../lib/controller/tunnel'); + +const AUTH_TOKEN = 'test-token'; + +describe('Tunnel cluster', () => { + + it('connect to http://', done => { + createServer().then(server => { + const tunnel = new TunnelClusterController({remoteSessionUrl: `${server.host}/connect/`}); + const origin = 'http://localhost:12345'; + return tunnel.connect({ + origin, + authorization: AUTH_TOKEN + }) + .then(cluster => { + assert.equal(cluster.options.origin, origin); + assert.equal(cluster.options.localSite, origin); + assert.equal(cluster.options.publicId, 'foo.livestyle.io'); + + let list = tunnel.list(); + assert.equal(list.length, 1); + assert.equal(list[0].origin, origin); + assert.equal(list[0].localSite, origin); + + return destroy(cluster); + }) + .then(() => server.shutdown()) + .then(() => { + // connections must be properly closed + assert.equal(tunnel.list().length, 0); + }); + }) + .then(done, done); + }); + + it('connect to file://', done => { + createServer().then(server => { + const tunnel = new TunnelClusterController({remoteSessionUrl: `${server.host}/connect/`}); + const origin = 'file://' + path.resolve(__dirname, 'static'); + return tunnel.connect({ + origin, + authorization: AUTH_TOKEN + }) + .then(cluster => { + let tempServer = fileServer.find(origin); + assert(tempServer); + assert.equal(fileServer.list().length, 1); + assert.equal(cluster.options.origin, origin); + assert.equal(cluster.options.publicId, 'foo.livestyle.io'); + assert.equal(cluster.options.localSite, tempServer.host); + + // make sure temp server works properly + return checkRequest(tempServer) + .then(() => destroy(cluster)); + }) + .then(() => server.shutdown()) + .then(() => { + // connections and servers must be properly closed + assert.equal(tunnel.list().length, 0); + assert.equal(fileServer.list().length, 0); + });; + }) + .then(done, done); + }); +}); + +function createServer() { + return new Promise((resolve, reject) => { + const app = connect().use((req, res) => { + let error = (code, message) => { + res.statusCode = code; + res.end(JSON.stringify({error: {message}})); + }; + + if (req.method !== 'POST') { + return error(405, `${req.method} method is not allowed`); + } + + readRequestData(req) + .then(data => { + let auth = req.headers.authorization; + res.setHeader('content-type', 'application/json'); + + if (auth !== AUTH_TOKEN) { + return error(401, 'Invalid token'); + } + + return res.end(JSON.stringify({ + sessionId: 'foo', + publicId: 'foo.livestyle.io', + localSite: data.localSite, + connectUrl: 'http://localhost:8902/fake-session', + expiresAt: Date.now() + 1000 + })); + }) + .catch(err => error(500, err.message)); }); - tc.create({ - publicId: 'rv-test.livestyle.io', - localSite: 'http://localhost:8901', - connectUrl: 'http://localhost:8902/fake-session', - maxConnections: 2, - retryCount: 2, - retryDelay: 100, + const server = http.createServer(app); + server.once('error', reject); + // start server on random port + server.listen(0, 'localhost', () => { + var addr = server.address(); + var host = `http://${addr.address}:${addr.port}`; + server.host = host; + resolve(server); }); + + server.shutdown = () => new Promise(resolve => server.close(resolve)); }); +} - it('"file:" origin', function(done) { - tc.once('clusterCreate', function(cluster) { - // server must be closed when session is destroyed - var server = fileServer.find(cluster.options.localSite); - assert(server); - server.once('close', done); - http.get(cluster.options.localSite + '/index.html', res => { - assert.equal(res.statusCode, 200); - assert.equal(res.headers['content-type'], 'text/html; charset=UTF-8'); - cluster.destroy(); - }).on('error', done); - }); +function destroy(item) { + return new Promise((resolve, reject) => { + item + .once('destroy', err => err ? reject(err) : resolve()) + .once('error', reject) + .destroy(); + }); +} - fileServer(`file://` + path.join(__dirname, 'static').replace(/\\/g, '/')) - .then(origin => { - tc.create({ - publicId: 'rv-test.livestyle.io', - localSite: origin, - connectUrl: 'http://localhost:8902/fake-session' - }); - }, done); +function readRequestData(stream) { + return new Promise((resolve, reject) => { + let chunks = []; + stream + .on('data', chunk => chunks.push(chunk)) + .on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString()))) + .on('error', reject) + .resume(); + }); +} + +function checkRequest(server, url) { + return new Promise((resolve, reject) => { + request(url || `${server.host}/index.html`, (err, resp, body) => { + if (!err && body.length) { + resolve(server); + } else { + reject(err || new Error('Empty response body')); + } + }); }); -}); \ No newline at end of file +} diff --git a/ui/controller.js b/ui/controller.js index 5c59a52..6d66d8d 100644 --- a/ui/controller.js +++ b/ui/controller.js @@ -3,14 +3,14 @@ */ 'use strict'; -var ipc = require('electron').ipcRenderer; -var shell = require('electron').shell; -var chrome = require('./chrome'); -var sublimeText = require('./sublime-text'); -var rv = require('./rv-sessions'); -var $ = require('./utils').qs; -var closest = require('./utils').closest; -var apps = require('../lib/apps'); +const ipc = require('electron').ipcRenderer; +const shell = require('electron').shell; +const chrome = require('./chrome'); +const sublimeText = require('./sublime-text'); +const rv = require('./rv-sessions'); +const $ = require('./utils').qs; +const closest = require('./utils').closest; +const apps = require('../lib/apps'); function init() { var chromeRender = chrome($('.extension-item[data-extension-id=chrome]')); @@ -77,4 +77,4 @@ function notifyUpdateAvailable() { n.onclick = () => ipc.send('install-update'); } -init(); \ No newline at end of file +init(); diff --git a/ui/popup.js b/ui/popup.js index 6288da1..83f1c44 100644 --- a/ui/popup.js +++ b/ui/popup.js @@ -2,9 +2,9 @@ * Popup controller */ 'use strict'; -var $ = require('./utils').qs; -var $$ = require('./utils').qsa; -var closest = require('./utils').closest; +const $ = require('./utils').qs; +const $$ = require('./utils').qsa; +const closest = require('./utils').closest; module.exports = function() { document.addEventListener('click', function(evt) { @@ -19,7 +19,7 @@ module.exports = function() { } if (!closest(evt.target, '.popup-content') || closest(evt.target, '.popup-close')) { - // clicked outside popup content or on popup close icon: + // clicked outside popup content or on popup close icon: // hide all popups return hideAll(); } @@ -42,4 +42,4 @@ function hide(popup) { function hideAll() { $$('.popup').forEach(hide); -} \ No newline at end of file +} diff --git a/ui/rv-sessions.js b/ui/rv-sessions.js index 1767fa5..75b3766 100644 --- a/ui/rv-sessions.js +++ b/ui/rv-sessions.js @@ -3,9 +3,9 @@ */ 'use strict'; -var ipc = require('electron').ipcRenderer; -var $ = require('./utils').qs; -var closest = require('./utils').closest; +const ipc = require('electron').ipcRenderer; +const $ = require('./utils').qs; +const closest = require('./utils').closest; module.exports = function(elem) { elem.addEventListener('click', function(evt) { @@ -35,4 +35,4 @@ module.exports = function(elem) { `; }).join('\n'); } -}; \ No newline at end of file +}; diff --git a/ui/sublime-text-status.js b/ui/sublime-text-status.js index 826ea94..b4161b6 100644 --- a/ui/sublime-text-status.js +++ b/ui/sublime-text-status.js @@ -7,7 +7,6 @@ */ 'use strict'; -const extend = require('xtend'); const pluginStatus = require('./plugin-status'); module.exports = function(model) { @@ -21,7 +20,7 @@ module.exports = function(model) { if (errState.value.errorCode === 'ENOSUBLIMETEXT' && st2.state !== st3.state) { let result = errState === st2 ? st3 : st2; if (result.state === 'not-installed') { - result = extend(result, {missing: [result === st2 ? 'st2' : 'st3']}); + result = Object.assign({}, result, {missing: [result === st2 ? 'st2' : 'st3']}); } return result; } @@ -32,7 +31,7 @@ module.exports = function(model) { if (is('not-installed')) { // both plugins are not installed if (st2.state === st3.state) { - return extend(st2, {missing: ['st2', 'st3']}); + return Object.assign({}, st2, {missing: ['st2', 'st3']}); } // one of the plugins is not installed @@ -47,4 +46,4 @@ module.exports = function(model) { return is('progress') || st3 || st2; }; -module.exports.update = pluginStatus.update; \ No newline at end of file +module.exports.update = pluginStatus.update;