zombiebox
Version:
ZombieBox is a JavaScript framework for development of Smart TV and STB applications
816 lines (691 loc) • 20.5 kB
JavaScript
/*
* 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 fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const kleur = require('kleur');
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 PathHelper = require('./path-helper');
const BuildHelper = require('./build-helper');
const Config = require('./config/config');
const TemplateHelper = require('./template-helper');
const VersionsChecker = require('./versions-checker');
const logger = require('./logger').createChild('APP');
/* eslint-disable node/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 {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._ = _;
data.config = this.getConfig();
}
);
/**
* @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) {
logger.warn('Skipped verification of version compatibility between ZombieBox components');
} else {
this._checkVersions();
}
if (this._config.templates.length) {
this._pathHelper.setAdditionalTemplateLocations(this._config.templates);
}
this._initCodeSource();
}
/**
* @return {Promise}
*/
ready() {
logger.silly(`Readying application`);
return this._codeSource.ready();
}
/**
* @return {BuildHelper}
*/
getBuildHelper() {
return this._buildHelper;
}
/**
* @return {PathHelper}
*/
getPathHelper() {
return this._pathHelper;
}
/**
* @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 || [])
.map((filePath) => this._pathHelper.resolveAbsolutePath(filePath))
.filter(
(filename) => {
const exists = fs.existsSync(filename);
if (!exists) {
logger.warn(`File ${kleur.underline(filename)} does not exist`);
}
return exists;
}
);
}
logger.silly(`Compiling js files: \n\t${files.join('\n\t')}`);
return files;
}
/**
* @return {Array<string>}
*/
async 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);
}
}
logger.debug(`CSS files priorities: ${componentsOrder.join(', ')}`);
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) {
if (entity.css) {
for (const filePath of entity.css) {
const absolutePath = this._pathHelper.resolveAbsolutePath(filePath);
const exists = await fse.exists(absolutePath);
if (exists) {
files.push(absolutePath);
} else {
logger.warn(`File ${kleur.underline(absolutePath)} does not exist`);
}
}
}
}
logger.silly(`Compiling css files: \n\t${files.join('\n\t')}`);
return files;
}
/**
* @return {Array<AbstractPlatform>}
*/
getPlatforms() {
return this._addonLoader.getPlatforms();
}
/**
* @param {string} name
* @return {?AbstractPlatform}
*/
getPlatformByName(name) {
return this.getPlatforms()
.find((platform) => platform.getName() === name);
}
/**
* @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.
*/
async buildCode() {
await this._codeSource.generated.generate();
}
/**
* 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('/');
const absolutePath = map.get(componentName);
if (absolutePath) {
return path.join(absolutePath, ...parts);
}
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).replace(/\\/g, '/');
}
}
return null;
}
/**
* @param {AbstractPlatform} platform
* @return {Promise}
*/
async build(platform) {
logger.verbose(`Building application for ${platform.getName()}`);
const buildHelper = this.getBuildHelper();
const distDir = this._pathHelper.getDistDir({
baseDir: this._config.project.dist,
version: this.getAppVersion(),
platformName: platform.getName()
});
/**
* @param {string} filename
* @param {string} content
* @return {Promise}
*/
const writeFileToDist = (filename, content) => fse.writeFile(path.join(distDir, filename), content, 'utf8');
logger.debug(`Cleaning up ${distDir}`);
await fse.emptyDir(distDir);
await this._runBuildHook(this._pathHelper.getPreBuildHook(), platform);
this._config.appendObject({
define: {
PLATFORM_NAME: platform.getName()
}
});
await this._codeSource.generated.generateDefines();
const [cssCompilationResult, jsCompilationResult] = await Promise.all([
buildHelper.getCompressedStyles(distDir),
buildHelper.getCompressedScripts()
]);
const options = await this.collectResourcesFromConfigByPlatform(platform);
const fileWriteTasks = [
buildHelper.copyStaticFiles(distDir)
];
if (this._config.build.inlineCSS) {
options.inlineStyles.push(cssCompilationResult.css);
} else {
options.styles.push('app.css');
fileWriteTasks.push(writeFileToDist('app.css', cssCompilationResult.css));
}
if (this._config.build.inlineJS) {
options.inlineScripts.push(jsCompilationResult.stdout);
} else {
const libsSources = options.inlineScripts.join(';\n');
options.inlineScripts = [];
options.scripts.push('libs.js', 'app.js');
fileWriteTasks.push(
writeFileToDist('libs.js', libsSources),
writeFileToDist('app.js', jsCompilationResult.stdout)
);
}
const indexHtmlContent = this.getIndexHTMLContent(options);
fileWriteTasks.push(writeFileToDist('index.html', indexHtmlContent));
await Promise.all(fileWriteTasks);
const cssCompilationReport = cssCompilationResult.messages;
let jsCompilationReport;
try {
jsCompilationReport = JSON.parse(jsCompilationResult.stderr);
} catch (e) {
logger.debug(jsCompilationResult.stderr);
logger.debug(e.stack);
const match = e.message.match(/Unexpected token (?:.+) in JSON at position (?<position>\d+)/);
let message = `Failed to parse GCC output: ${e.message}`;
if (match && match.groups.position) {
message += '\n' + jsCompilationResult.stderr.slice(parseInt(match.groups.position, 10));
}
throw new Error(message);
}
logger.silly(`GCC compilation report: \n${JSON.stringify(jsCompilationReport, null, '\t')}`);
logger.silly(`CSS compilation report: \n${JSON.stringify(cssCompilationReport, null, '\t')}`);
cssCompilationReport.forEach((message) => {
// TODO: fancier output
logger.info(`CSS compilation message: ${JSON.stringify(message, null, 4)}`);
});
jsCompilationReport.forEach((message) => {
if (message.level === 'info') {
logger.output(message.description);
} else if (message.level === 'error' || message.level === 'warning') {
// Highlight zombiebox package name
const filename = message.source && message.source.replace(
/(?<=\/)zombiebox.+?(?=\/)/g,
(name) => kleur.bold(name)
);
const logLine = [
filename && `${filename}:${message.line}:${message.column}`,
`${message.level.toUpperCase()} [${message.key}]`,
message.description,
message.context
].filter(Boolean).join('\n') + '\n';
logger[{
'error': 'error',
'warning': 'warn'
}[message.level]](logLine);
} else {
logger.debug(`Unrecognized GCC message \n${JSON.stringify(message, null, '\t')}`);
}
});
if (jsCompilationResult.stdout) {
logger.output(`Compilation successful!`);
} else {
logger.error(`Compilation failed!`);
}
await this._runBuildHook(this._pathHelper.getPostBuildHook(), platform);
}
/**
* @param {AbstractPlatform} platform
* @return {Promise}
*/
async pack(platform) {
const distDir = this._pathHelper.getDistDir({
baseDir: this._config.project.dist,
version: this.getAppVersion(),
platformName: platform.getName()
});
logger.verbose(`Packaging application`);
await platform.pack(this, distDir);
}
/**
* Serve development version of application
*/
serve() {
const server = new Server(this);
const serverConfig = this._config.devServer;
server.logServer('/log');
if (serverConfig.enableRawProxy) {
server.rawProxy('/proxy');
}
const proxyMap = serverConfig.proxy;
Object.keys(proxyMap)
.forEach((path) => {
server.proxy(path, proxyMap[path]);
});
let staticFiles = {};
for (const entity of this._config.include) {
if (entity.static) {
staticFiles = Object.assign(staticFiles, entity.static);
}
}
logger.silly(`Serving static files: \n${JSON.stringify(staticFiles, null, '\t')}`);
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 {
logger.warn(`Can't serve static path ${kleur.green(alias)} from ${kleur.underline(absolutePath)}`);
}
}
this._codeSource.watch();
server.start(serverConfig.port)
.then((addresss) => {
logger.output(`Server started at ${kleur.underline(addresss)}`);
}, (err) => {
logger.error(`Error starting server: ${err.toString()}`);
logger.debug(err.stack);
});
}
/**
* TODO: rename options (here and in template) to be consistent with config naming
* Renders index.html adding all the entities included in config
* @param {{
* inlineScripts: (Array<string>|undefined),
* inlineStyles: (Array<string>|undefined),
* scripts: (Array<string>|undefined),
* styles: (Array<string>|undefined),
* modules: (Array<string>|undefined)
* }=} options
* @return {string}
*/
getIndexHTMLContent(options = {}) {
logger.verbose(`Rendering index.html`);
const defaultOptions = {
inlineScripts: [],
inlineStyles: [],
scripts: [],
styles: [],
modules: []
};
const opts = Object.assign(defaultOptions, options);
logger.silly(`External js scripts: \n\t${opts.scripts.join('\n\t')}`);
logger.silly(`Inlined css files: \n\t${opts.styles.join('\n\t')}`);
return this._templateHelper.render('index.html.tpl', opts);
}
/**
* @param {AbstractPlatform=} platform
* @return {{
* inlineScripts: Array<string>,
* inlineStyles: Array<string>,
* scripts: Array<string>,
* styles: Array<string>
* }}
*/
async collectResourcesFromConfigByPlatform(platform = undefined) {
let platformFilter = () => true;
if (platform) {
const otherIncludes = this.getPlatforms()
.filter((otherPlatform) => otherPlatform !== platform)
.map((otherPlatform) => otherPlatform.getConfig().include || [])
.reduce((all, other) => all.concat(other), []);
platformFilter = (entity) => {
const shouldExclude = otherIncludes.includes(entity);
if (shouldExclude) {
logger.debug(
`Include entity "${entity.name}" was not included because ` +
`it came from a platform that is not ${platform.getName()}`
);
}
return !shouldExclude;
};
}
const inlineScriptFiles = [];
const options = {
scripts: [],
inlineScripts: [],
styles: [],
inlineStyles: []
};
for (const entity of this._config.include.filter(platformFilter)) {
inlineScriptFiles.push(...(entity.inlineScripts || []));
// Not adding modules because those were added to compilation
options.scripts.push(...(entity.externalScripts || []));
// Not adding css either – they go through PostCSS
options.styles.push(...(entity.externalCss || []));
}
logger.silly(`Inlined js scripts: \n\t${inlineScriptFiles.join('\n\t')}`);
options.inlineScripts = await Promise.all(inlineScriptFiles
.map((filename) => this._pathHelper.resolveAbsolutePath(filename))
.filter((filename) => {
const exists = fse.exists(filename);
if (!exists) {
logger.warn(`File ${kleur.underline(filename)} does not exist`);
}
return exists;
})
.map((filename) => fse.readFile(filename, 'utf8')));
return 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');
if (fs.existsSync(projectConfigPath)) {
this._config.loadFile(projectConfigPath);
} else {
logger.debug(`Default application config does not exist at ${kleur.underline(projectConfigPath)}`);
}
this._loadCustomConfigs(customConfigs);
this._applyBuiltinDefines();
const configErrors = this._config.validateSchema();
if (configErrors.length) {
configErrors.forEach((error) => {
logger.warn(`Config error: Property ${error.property}: ${error.message}`);
});
throw new Error('Invalid project configuration');
}
logger.verbose(`Finalized config`);
}
/**
* @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) {
logger.debug(e.stack);
throw new Error(`Can't load addon at "${pathOrAddon}": ${e.toString()}`);
}
} 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) {
configs.forEach((pathOrObject) => {
const type = typeof pathOrObject;
if (type === 'string') {
this._config.loadFile(pathOrObject);
} else if (type === 'object') {
this._config.appendObject(pathOrObject);
} else {
throw new TypeError(
`Unexpected type "${type}" for config loading. ` +
`Pass a string to load config from the file system or a plain object.`
);
}
});
}
/**
* @protected
*/
_applyBuiltinDefines() {
const zbVersion = this.getZbPackageJson()['version'];
this._config.appendObject({
define: {
'NPM_PACKAGE_NAME': this._packageJson['name'],
'NPM_PACKAGE_VERSION': this._packageJson['version'],
'ZOMBIEBOX_VERSION': zbVersion
}
});
}
/**
* Initialize code providers.
* @protected
*/
_initCodeSource() {
this._codeSource = new CodeSource(
this._addonLoader,
this._pathHelper,
this._templateHelper,
this._config,
this._packageJson
);
this._addonLoader.getExtensions().forEach((extension) => {
extension.setCodeSource(this._codeSource);
});
}
/**
* @protected
*/
_checkVersions() {
logger.verbose(`Cross-checking ZombieBox packages dependencies`);
const packages = [this.getAppPackageJson(), this.getZbPackageJson(), ...this._addonLoader.getPackageJsons()];
const checker = new VersionsChecker(this.getAppPackageJson(), packages);
const {warns, errors} = checker.check();
warns.forEach((message) => logger.warn(message));
if (errors.length) {
throw new Error(errors.join('\n\n'));
}
}
/**
* @param {string} file
* @param {AbstractPlatform} platform
* @return {Promise}
* @protected
*/
async _runBuildHook(file, platform) {
if (!await fse.pathExists(file)) {
return;
}
const buildDir = this._pathHelper.getDistDir({
baseDir: this._config.project.dist,
version: this.getAppVersion(),
platformName: platform.getName()
});
// eslint-disable-next-line node/global-require
const externalCallback = require(file);
await new Promise((resolve) => {
externalCallback(resolve, {
app: this,
buildDir,
platform: platform,
platformName: platform.getName()
});
});
}
}
module.exports = Application;