UNPKG

zombiebox

Version:

ZombieBox is a JavaScript framework for development of Smart TV and STB applications

456 lines (392 loc) 10.9 kB
/* * This file is part of the ZombieBox package. * * Copyright © 2012-2021, Interfaced * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ const fse = require('fs-extra'); const http = require('http'); const {URL} = require('url'); const kleur = require('kleur'); const espree = require('espree'); const connect = require('connect'); const send = require('send'); const httpProxy = require('http-proxy'); const morgan = require('morgan'); const postcss = require('postcss'); const postcssPresetEnv = require('postcss-preset-env'); const postcssValuesParser = require('postcss-values-parser'); const serveStatic = require('serve-static'); const zbLogServer = require('zb-log-server'); const Application = require('./application'); const PathHelper = require('./path-helper'); const ServerCache = require('./server-cache'); const logger = require('./logger').createChild('Server'); /** */ class Server { /** * @param {Application} application */ constructor(application) { /** * @type {Application} * @protected */ this._application = application; /** * @type {Function} * @protected */ this._app = connect(); /** * @type {Object} * @protected */ this._proxyServer = httpProxy.createProxyServer(); /** * @type {?http.Server} * @protected */ this._httpServer = null; /** * @type {?number} * @protected */ this._httpPort = null; /** * @type {postcss.Processor} * @protected */ this._postcss; /** * @type {ServerCache} * @protected */ this._stylesCache = new ServerCache((content, path) => this._preprocessStyle(content, path)); /** * @type {ServerCache} * @protected */ this._modulesCache = new ServerCache((content, path) => this._preprocessModule(content, path)); const postcssConfig = this._application.getConfig().postcss; this._postcss = postcss([ postcssPresetEnv({...postcssConfig.presetEnv}), ...postcssConfig.filePlugins, postcss.plugin('resolve-imports', () => (root) => { root.walkAtRules('import', (rule) => { let node = postcssValuesParser.parse(rule.params).nodes[0]; if (node.type === 'func' && node.name === 'url') { node = node.nodes[0]; } if (node.type !== 'quoted') { return; } const importPath = node.value.substring(1, node.value.length - 1); if (PathHelper.isLocal(importPath)) { return; } const fsPath = this._application.aliasedPathToFsPath(importPath); const webPath = this.getStyleWebPath(fsPath); const modifiedValue = rule.params.replace(importPath, webPath); rule.replaceWith(new postcss.AtRule({name: 'import', params: modifiedValue})); logger.silly(`Replacing CSS import ${importPath} with ${webPath}`); }); }) ]); this._app.use(morgan('dev', {skip: (req, res) => res.statusCode < 400})); this._initEndpointMiddleware(); this._initModulesMiddleware(); this._initStylesMiddleware(); this._initErrorMiddleware(); } /** * @param {string|Function} route or middleware * @param {Function=} middleware * @return {connect} */ use(route, middleware) { return this._app.use(route, middleware); } /** * @param {string} alias * @param {string} dir */ serveStatic(alias, dir) { this.use(alias, serveStatic(dir)); } /** * @param {string} route */ rawProxy(route) { logger.info(`Proxy enabled at ${kleur.cyan(route + '/?url=...')}`); this.use(route, (req, res) => { // see https://github.com/nodejs/node/issues/12682 const address = (new URL(req.url, 'request-target://')).searchParams.get('url'); this._proxyServer.web(req, res, { target: address }); }); } /** * @param {string} route * @param {string} address */ proxy(route, address) { logger.info(`Proxying path ${kleur.green(route)} from ${kleur.cyan(address)}`); this.use(route, (req, res) => { req.headers.host = (new URL(address)).host; this._proxyServer.web(req, res, { target: address }); }); } /** * @param {string} route */ logServer(route) { logger.verbose(`Log server started at ${route}`); this.use(route, zbLogServer); } /** * @param {number=} port * @return {Promise<string>} */ start(port = Server.DEFAULT_PORT) { this._httpPort = port; this._httpServer = http.createServer(this._app); return new Promise((resolve, reject) => { this._httpServer.listen(this._httpPort); this._httpServer.on('error', (e) => { if (e.code === 'EADDRINUSE') { e.message = ( `Port ${this._httpPort} is already used by another process. ` + `Tip: to find this process use command like: \`lsof -i:${this._httpPort}\`` ); } reject(e); }); this._httpServer.on('listening', () => { resolve(this._getAddress()); }); }); } /** * @param {string} fsPath * @return {string} */ getModuleWebPath(fsPath) { return '/modules/' + this._application.fsPathToAliasedPath(fsPath); } /** * @param {string} fsPath * @return {string} */ getStyleWebPath(fsPath) { return '/styles/' + this._application.fsPathToAliasedPath(fsPath); } /** * @return {string} * @protected */ _getAddress() { return `http://localhost${this._httpPort === 80 ? '' : ':' + this._httpPort}/`; } /** * @param {http.ServerResponse} res * @protected */ async _respondIndexHTMLPage(res) { const config = this._application.getConfig(); const platform = this._application.getPlatformByName('pc'); const {importEntryPoints} = config.postcss; const styles = (importEntryPoints || await this._application.getSortedStyles()) .map((fsPath) => this.getStyleWebPath(fsPath)); const entryPoint = this.getModuleWebPath(this._application.getGeneratedEntryPoint()) .replace(/\.js$/, ''); const modules = [entryPoint]; logger.info(`Rendering application`); const {backdoor} = config.devServer; if (backdoor && await fse.pathExists(backdoor)) { logger.info(`Development backdoor plugged in`); modules.unshift(this.getModuleWebPath(backdoor)); } const options = await this._application.collectResourcesFromConfigByPlatform(platform); options.styles = options.styles.concat(styles); options.modules = modules; res.setHeader('Content-Type', 'text/html; charset=UTF-8'); res.end(this._application.getIndexHTMLContent(options)); } /** * @protected */ _initEndpointMiddleware() { this._app.use(async (req, res, next) => { const {pathname} = new URL(req.url, 'request-target://'); switch (pathname) { case '/': case '/index.html': await this._respondIndexHTMLPage(res); break; // For backward compatibility case '/es5': case '/es5.html': case '/es6': case '/es6/': case '/es6.html': case '/bundle.html': logger.warn(`Obsolete entry point: ${pathname}`); res.writeHead(301, {'Location': '/'}); res.end(); break; default: next(); } }); } /** * @param {string} content * @param {string} fsPath * @return {string} * @protected */ _preprocessModule(content, fsPath) { let ast; try { ast = espree.parse(content, { sourceType: 'module', ecmaVersion: 2019 }); } catch (e) { logger.error(`Error while resolving module paths in ${kleur.underline(fsPath)}: ${e.toString()}`); logger.debug(e.stack); } if (!ast) { return content; } let patchedContent = content; for (const node of ast.body.reverse()) { // Imports can only be at top level as per specification if (node.type === 'ImportDeclaration') { const source = node.source; if (source.type === 'Literal' && !PathHelper.isLocal(source.value)) { const webPath = '/modules/' + source.value; const replacement = source.raw.replace(source.value, webPath); // logger.silly( // `Replacing import path ${kleur.red(source.value)} ` + // `with ${kleur.green(webPath)} in ` + // kleur.underline(fsPath) // ); patchedContent = patchedContent.slice(0, source.start) + replacement + patchedContent.slice(source.end); } } } return patchedContent; } /** * @protected */ _initModulesMiddleware() { this._app.use('/modules', (req, res, next) => { // Serve js modules const aliasedPath = this._getFilePathFromQuery(req.url); let fsPath = this._application.aliasedPathToFsPath(aliasedPath); if (!fsPath) { next(new Error(`Can't resolve aliased module path ${kleur.bold(aliasedPath)}`)); return; } if (!fsPath.endsWith('.js')) { fsPath += '.js'; } this._modulesCache.get(fsPath) .then((content) => { res.setHeader('Content-Type', 'application/javascript; charset=UTF-8'); res.end(content); }); }); } /** * @param {string} content * @param {string} fsPath * @return {string} * @protected */ _preprocessStyle(content, fsPath) { const pluginNames = this._postcss.plugins.map((plugin) => plugin && plugin.postcssPlugin).join(', '); logger.silly(`Running ${kleur.cyan(pluginNames)} on ${kleur.underline(fsPath)}`); return this._postcss.process(content, {from: fsPath}) .then((result) => result.css); } /** * @protected */ _initStylesMiddleware() { /** * @param {string} str * @return {number} */ function lengthInUtf8Bytes(str) { // Matches only the 10.. bytes that are non-initial characters in a multi-byte sequence. const m = encodeURIComponent(str).match(/%[89ABab]/g); return str.length + (m ? m.length : 0); } this._app.use('/styles', (req, res, next) => { const aliasedPath = this._getFilePathFromQuery(req.url); const fsPath = this._application.aliasedPathToFsPath(aliasedPath); if (!fsPath) { next(new Error(`Can't resolve aliased css file path ${kleur.bold(aliasedPath)}`)); return; } if (fsPath.endsWith('.css')) { try { res.setHeader('Content-Type', 'text/css; charset=UTF-8'); this._stylesCache.get(fsPath) .then((styleContent) => { res.setHeader('Content-Length', lengthInUtf8Bytes(styleContent)); res.end(styleContent); }); } catch (err) { next(err); } } else { send(req, fsPath).pipe(res); } }); } /** * @protected */ _initErrorMiddleware() { this._app.use((error, req, res, next) => { let message = error.message; if (req.headers.referer) { const referer = req.headers.referer.split(this._getAddress()).pop(); if (referer) { message += `\n\tReferrer: ${kleur.underline(referer)}`; } } logger.error(message); next(); }); } /** * @param {string} url * @return {string} * @protected */ _getFilePathFromQuery(url) { return url .replace(/^\//, '') .replace(/\?.+$/, ''); } } /** * @const {number} */ Server.DEFAULT_PORT = 80; module.exports = Server;