UNPKG

zombiebox

Version:

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

715 lines (598 loc) 16.5 kB
/* * This file is part of the ZombieBox package. * * Copyright © 2012-2019, Interfaced * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ const fs = require('fs'); const fse = require('fs-extra'); const path = require('path'); const chalk = require('chalk'); const _ = require('lodash'); const EventEmitter = require('events').EventEmitter; const AddonLoader = require('./addons/loader'); const AbstractAddon = require('./addons/abstract-addon'); const AbstractPlatform = require('./addons/abstract-platform'); const CodeSource = require('./sources/code-source'); const Server = require('./server'); const StylesCache = require('./styles-cache'); const PathHelper = require('./path-helper'); const BuildHelper = require('./build-helper'); const Config = require('./config/config'); const TemplateHelper = require('./template-helper'); const VersionsChecker = require('./versions-checker'); /* eslint-disable global-require */ /** */ class Application extends EventEmitter { /** * @param {string} root * @param {Array<string|Object>=} customConfigs * @param {Array<string|AbstractAddon>=} customAddons */ constructor(root, customConfigs = [], customAddons = []) { super(); /** * @type {CodeSource} * @private */ this._codeSource = null; /** * @type {StylesCache} * @private */ this._stylesCache = null; /** * @type {PathHelper} * @private */ this._pathHelper = new PathHelper(root); /** * @type {BuildHelper} * @private */ this._buildHelper = new BuildHelper(this); /** * @type {Object} * @private */ this._packageJson = require(this._pathHelper.getPackageJson()); /** * @type {TemplateHelper} * @private */ this._templateHelper = new TemplateHelper( () => this._pathHelper.getTemplateLocations(), (data) => (data._ = _) ); /** * @type {Config} * @private */ this._config = new Config(); /** * @type {AddonLoader} * @private */ this._addonLoader = new AddonLoader(this._pathHelper.getProjectModulesDir()); /** * @type {Map<string, string>} * @private */ this._componentAliases; /** * @type {Promise} * @private */ this._readyPromise; this._initAddons(customAddons); this._initConfigs(customConfigs); // Check the versions compatibility before further actions if (!this._config.skipVersionsCheck) { this._checkVersions(); } if (this._config.templates.length) { this._pathHelper.setAdditionalTemplateLocations(this._config.templates); } } /** * @return {Promise} */ ready() { // TODO: First, this initializes classes and collects files // Then it starts watching source directories // But we only need to watch sources when running dev server and not when Application class was created // programmatically (CLI, etc) if (!this._readyPromise) { this._initCodeSource(); this._readyPromise = this._codeSource.ready(); } return this._readyPromise; } /** * @return {BuildHelper} */ getBuildHelper() { return this._buildHelper; } /** * @return {PathHelper} */ getPathHelper() { return this._pathHelper; } /** * @return {StylesCache} */ getStylesCache() { return this._stylesCache; } /** * @return {?CodeSource} */ getCodeSource() { return this._codeSource; } /** * @return {Array<string>} */ getCompilationScripts() { let files = this._codeSource.all.getJSFiles(); for (const entity of this._config.include) { files = files.concat(entity.modules || []); } return files.map((filePath) => this._pathHelper.resolveAbsolutePath(filePath)) .filter((filePath) => fs.existsSync(filePath)); } /** * @return {Array<string>} */ getSortedStyles() { // ZombieBox first const componentsOrder = ['zb']; // Addons (extensions and platforms) this._addonLoader.getAddons().forEach((addon) => componentsOrder.push(addon.getName())); // Application styles for (const componentName of this._codeSource.aliasedSources.keys()) { if (!componentsOrder.includes(componentName)) { componentsOrder.push(componentName); } } let files = []; componentsOrder.forEach((componentName) => { const componentStyles = this._codeSource.aliasedSources.get(componentName).getCSSFiles(); files = files.concat(componentStyles); }); // Finally anything included as extra entities for (const entity of this._config.include) { files = files.concat(entity.css || []); } return files.map((filePath) => this._pathHelper.resolveAbsolutePath(filePath)) .filter((filePath) => fs.existsSync(filePath)); } /** * @return {Array<AbstractPlatform>} */ getPlatforms() { return this._addonLoader.getPlatforms(); } /** * @return {Object} */ getAppPackageJson() { return this._packageJson; } /** * @return {string} */ getAppVersion() { return this.getAppPackageJson().version; } /** * @return {string} */ getGeneratedEntryPoint() { return path.join(this._pathHelper.resolveAbsolutePath(this._config.generatedCode), 'app.js'); } /** * @return {Object} */ getZbPackageJson() { return require(path.join(this._pathHelper.getInstalledZbPath(), 'package.json')); } /** * @return {Config} */ getConfig() { return this._config; } /** * Compile templates, build base classes, etc. */ buildCode() { this._codeSource.cache.buildCode(); } /** * Returns paths to all modules necessary for building * @return {Map<string, string>} */ getAliases() { return this._componentAliases || this.recalculateAliases(); } /** * @return {Map<string, string>} */ recalculateAliases() { this._componentAliases = new Map(); this._componentAliases.set('generated', this._pathHelper.resolveAbsolutePath(this._config.generatedCode)); if (this._config.devServer.backdoor) { this._componentAliases.set( 'backdoor', this._pathHelper.resolveAbsolutePath(this._config.devServer.backdoor) ); } for (const [alias, fsSource] of this._codeSource.aliasedSources.entries()) { this._componentAliases.set(alias, fsSource.getRoot()); } const projectAliases = this._config.aliases; for (const [name, root] of Object.values(projectAliases)) { this._componentAliases.set(name, this._pathHelper.resolveAbsolutePath(root)); } for (const entity of this._config.include) { if (entity.aliases) { for (const [name, root] of Object.entries(entity.aliases)) { this._componentAliases.set(name, this._pathHelper.resolveAbsolutePath(root)); } } } return this._componentAliases; } /** * @param {string} aliasedPath * @return {?string} */ aliasedPathToFsPath(aliasedPath) { const map = this.getAliases(); if (!map) { return null; } const [componentName, ...parts] = aliasedPath.split(path.sep); const absolutePath = map.get(componentName); if (absolutePath) { return [absolutePath, ...parts].join('/'); } return null; } /** * @param {string} fsPath * @return {?string} */ fsPathToAliasedPath(fsPath) { const map = this.getAliases(); if (!map) { return null; } for (const [alias, path] of map) { if (fsPath.startsWith(path)) { return alias + fsPath.slice(path.length); } } return null; } /** * TODO: reduce complexity * @param {Array<AbstractPlatform>} platforms * @return {Promise} */ compile(platforms) { let resolvePromise = () => {/* Noop */}; let rejectPromise = () => {/* Noop */}; const deferred = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); const mkPlatformDir = (name) => { const platformDir = this._pathHelper.getDistDir({ baseDir: this._config.project.dist, version: this.getAppVersion(), platformName: name }); if (fs.existsSync(platformDir)) { fse.removeSync(platformDir); } fse.ensureDirSync(platformDir); console.log(`Compile app for ${chalk.green(name)} platform in ${chalk.cyan(platformDir)}`); return platformDir; }; const runExternalScript = (scriptName, buildDir, platformName, platform, callback) => { if (fs.existsSync(scriptName)) { try { // eslint-disable-next-line global-require const externalCallback = require(scriptName); externalCallback(callback, { app: this, buildDir, platformName, platform: platform || null }); } catch (e) { callback(e); } } else { callback(); } }; this.buildCode(); /** * @param {AbstractPlatform} platform * @param {function(*)} callback */ const buildPlatform = (platform, callback) => { const dir = mkPlatformDir(platform.getName()); const args = [dir, platform.getName(), platform]; runExternalScript(this._pathHelper.getPreBuildHook(), ...args, (error) => { if (error) { callback(error); return; } this._config.appendObject({ define: { PLATFORM_NAME: platform.getName() } }); this._codeSource.cache.generateDefines(); platform.buildApp(this, dir) .then((warnings) => { if (warnings) { const {report, truncated} = extractCompilationReport(warnings); if (truncated) { console.warn(truncated); } if (report) { console.log(`Compilation: ${report}`); } } runExternalScript(this._pathHelper.getPostBuildHook(), ...args, (error) => callback(error)); }, (error) => callback(error)); }); }; const buildFirstPlatform = (callback) => { const platform = platforms.shift(); if (platform) { buildPlatform(platform, callback); } else { callback(); } }; const buildPlatforms = () => { buildFirstPlatform(function done(error) { if (error) { if (error instanceof Error) { console.error(error); } else { const {report, truncated} = extractCompilationReport(error); if (truncated) { console.error(truncated); } if (report) { console.log(`Compilation: ${report}`); } } console.log(chalk.red('Build failed!')); rejectPromise(error); } else if (platforms.length === 0) { console.log(chalk.green('Build done')); resolvePromise(); } else { buildFirstPlatform(done); } }); }; // TODO: Use JSON output instead of parsing const extractCompilationReport = (text) => { const regex = /\n?\d+ error\(s\), \d+ warning\(s\)(?:, [\d.,]+% typed)?\n?/; const match = text.match(regex); let report = ''; let truncated = text; if (match) { report = match[0].trim(); truncated = text.replace(match[0], ''); } return { report, truncated }; }; buildPlatforms(); return deferred; } /** * Serve development version of application */ serve() { const server = new Server(this); const serverConfig = this._config.devServer; server.logServer('/log'); if (serverConfig.enableRawProxy) { console.log(`Proxy enabled at ${chalk.cyan('/proxy/?url=...')}`); server.rawProxy('/proxy'); } const proxyMap = serverConfig.proxy; Object.keys(proxyMap) .forEach((path) => { console.log(`Proxy path ${chalk.green(path)} from ${chalk.cyan(proxyMap[path])}`); server.proxy(path, proxyMap[path]); }); let staticFiles = {}; for (const entity of this._config.include) { if (entity.static) { staticFiles = Object.assign(staticFiles, entity.static); } } for (const [customPath, filePath] of Object.entries(staticFiles)) { const alias = `/${customPath.replace(/^\//, '')}`; const absolutePath = this._pathHelper.resolveAbsolutePath(filePath); if (fs.existsSync(absolutePath)) { server.serveStatic(alias, absolutePath); } else { console.warn(`Can't serve static path ${chalk.green(alias)} from ${chalk.cyan(absolutePath)}`); } } server.start(serverConfig.port) .then((message) => { console.log(message); }, (err) => { console.error(err.message); process.exit(1); }); } /** * TODO: rename options (here and in template) to be consistent with config naming * Renders index.html adding all the entities included in config * @param {Object=} extraOptions * @return {string} */ getIndexHTMLContent(extraOptions = {}) { const options = { modules: [], scripts: [], inlineScripts: [], styles: [], inlineStyles: [], ...extraOptions }; let inlineScriptFiles = []; for (const entity of this._config.include) { // Not adding modules because those were added to compilation options.scripts = options.scripts.concat(entity.externalScripts || []); inlineScriptFiles = inlineScriptFiles.concat(entity.inlineScripts || []); // Not adding css either – they go through PostCSS options.styles = options.styles.concat(entity.externalCss || []); } options.inlineScripts = options.inlineScripts.concat( inlineScriptFiles .map((filename) => this._pathHelper.resolveAbsolutePath(filename)) .filter((filename) => fs.existsSync(filename)) .map((filename) => fs.readFileSync(filename, 'utf8')) ); return this._templateHelper.render('index.html.tpl', options); } /** * @param {Array<string|AbstractAddon>=} customAddons * @protected */ _initAddons(customAddons) { this._addonLoader.loadFromPackageJson(this.getAppPackageJson()); if (customAddons) { this._loadCustomAddons(customAddons); } this._addonLoader.getAddons().forEach((addon) => { addon.setTemplateHelper(this._templateHelper); }); } /** * @param {Array<string|Object>} customConfigs * @protected */ _initConfigs(customConfigs) { const defaultConfigPath = path.join(this._pathHelper.getInstalledZbPath(), 'lib', 'config', 'default.js'); this._config.loadFile(defaultConfigPath); this._addonLoader.getAddons().forEach((addon) => { this._config.appendObject(addon.getConfig()); }); const projectConfigPath = path.join(this._pathHelper.getRootDir(), 'config.js'); this._loadCustomConfigs([projectConfigPath, ...customConfigs]); const configErrors = this._config.validateSchema(); if (configErrors.length) { configErrors.forEach((error) => { console.warn(`Config error: Property ${error.property}: ${error.message}`); }); throw new Error('Invalid project configuraion'); } } /** * @param {Array<string|AbstractAddon>} addons * @protected */ _loadCustomAddons(addons) { addons.forEach((pathOrAddon) => { const type = typeof pathOrAddon; if (type === 'string') { try { this._addonLoader.loadAddon(pathOrAddon); } catch (e) { throw new Error(`Can't load addon at "${pathOrAddon}": ${e.message}.`); } } else if (type === 'object') { this._addonLoader.addAddon(pathOrAddon); } else { throw new TypeError( `Unexpected type "${type}" for addon loading. ` + `Pass a string to load addon from the file system or an instance.` ); } }); } /** * @param {Array<string|Object>} configs * @protected */ _loadCustomConfigs(configs) { let loadedSome = false; configs.forEach((pathOrObject) => { const type = typeof pathOrObject; if (type === 'string') { try { this._config.loadFile(pathOrObject); loadedSome = true; } catch (e) { // ignore } } else if (type === 'object') { this._config.appendObject(pathOrObject); loadedSome = true; } else { throw new TypeError( `Unexpected type "${type}" for config loading. ` + `Pass a string to load config from the file system or a plain object.` ); } }); if (!loadedSome) { console.warn('Could not load any project configs'); } } /** * Initialize code providers. * @protected */ _initCodeSource() { this._codeSource = new CodeSource( this._addonLoader, this._pathHelper, this._templateHelper, this._config, this._packageJson ); this._stylesCache = new StylesCache( this._codeSource.fs, this._config.postcss ); } /** * @private */ _checkVersions() { const dependencies = this.getAppPackageJson().dependencies || {}; const packages = [this.getZbPackageJson(), ...this._addonLoader.getPackageJsons()]; const checker = new VersionsChecker(dependencies, packages); const {warns, errors} = checker.check(); warns.forEach((message) => console.warn(message)); if (errors.length) { throw new Error(errors.join('\n\n')); } } } module.exports = Application;