const path = require("path"); const fs = require("fs"); const glob = require("fast-glob"); const esbuild = require("esbuild"); const vue = require("esbuild-plugin-vue3"); const yargs = require("yargs"); const cliui = require("cliui")(); const chalk = require("chalk"); const html_plugin = require("./xhiveframework-html"); const vue_style_plugin = require("./xhiveframework-vue-style"); const rtlcss = require("rtlcss"); const postCssPlugin = require("@xhiveframework/esbuild-plugin-postcss2").default; const ignore_assets = require("./ignore-assets"); const sass_options = require("./sass_options"); const build_cleanup_plugin = require("./build-cleanup"); const { app_list, assets_path, apps_path, sites_path, get_app_path, get_public_path, log, log_warn, log_error, bench_path, get_redis_subscriber, } = require("./utils"); const argv = yargs .usage("Usage: node esbuild [options]") .option("apps", { type: "string", description: "Run build for specific apps", }) .option("skip_xhiveframework", { type: "boolean", description: "Skip building xhiveframework assets", }) .option("files", { type: "string", description: "Run build for specified bundles", }) .option("watch", { type: "boolean", description: "Run in watch mode and rebuild on file changes", }) .option("live-reload", { type: "boolean", description: `Automatically reload Desk when assets are rebuilt. Can only be used with the --watch flag.`, }) .option("production", { type: "boolean", description: "Run build in production mode", }) .option("run-build-command", { type: "boolean", description: "Run build command for apps", }) .option("save-metafiles", { type: "boolean", description: "Saves esbuild metafiles for built assets. Useful for analyzing bundle size. More info: https://esbuild.github.io/api/#metafile", }) .example("node esbuild --apps xhiveframework,xhiveerp", "Run build only for xhiveframework and xhiveerp") .example( "node esbuild --files xhiveframework/website.bundle.js,xhiveframework/desk.bundle.js", "Run build only for specified bundles" ) .version(false).argv; const APPS = (!argv.apps ? app_list : argv.apps.split(",")).filter( (app) => !(argv.skip_xhiveframework && app == "xhiveframework") ); const FILES_TO_BUILD = argv.files ? argv.files.split(",") : []; const WATCH_MODE = Boolean(argv.watch); const PRODUCTION = Boolean(argv.production); const RUN_BUILD_COMMAND = !WATCH_MODE && Boolean(argv["run-build-command"]); const TOTAL_BUILD_TIME = `${chalk.black.bgGreen(" DONE ")} Total Build Time`; const NODE_PATHS = [].concat( // node_modules of apps directly importable app_list .map((app) => path.resolve(get_app_path(app), "../node_modules")) .filter(fs.existsSync), // import js file of any app if you provide the full path app_list.map((app) => path.resolve(get_app_path(app), "..")).filter(fs.existsSync) ); execute().catch((e) => { console.error(e); process.exit(1); }); if (WATCH_MODE) { // listen for open files in editor event open_in_editor(); } async function execute() { console.time(TOTAL_BUILD_TIME); let results; try { results = await build_assets_for_apps(APPS, FILES_TO_BUILD); } catch (e) { log_error("There were some problems during build"); log(); log(chalk.dim(e.stack)); if (process.env.CI || PRODUCTION) { process.kill(process.pid); } return; } if (!WATCH_MODE) { log_built_assets(results); console.timeEnd(TOTAL_BUILD_TIME); log(); } else { log("Watching for changes..."); } for (const result of results) { await write_assets_json(result.metafile); } RUN_BUILD_COMMAND && run_build_command_for_apps(APPS); if (!WATCH_MODE) { process.exit(0); } } function build_assets_for_apps(apps, files) { let { include_patterns, ignore_patterns } = files.length ? get_files_to_build(files) : get_all_files_to_build(apps); return glob(include_patterns, { ignore: ignore_patterns }).then((files) => { let output_path = assets_path; let file_map = {}; let style_file_map = {}; let rtl_style_file_map = {}; for (let file of files) { let relative_app_path = path.relative(apps_path, file); let app = relative_app_path.split(path.sep)[0]; let extension = path.extname(file); let output_name = path.basename(file, extension); if ([".css", ".scss", ".less", ".sass", ".styl"].includes(extension)) { output_name = path.join("css", output_name); } else if ([".js", ".ts"].includes(extension)) { output_name = path.join("js", output_name); } output_name = path.join(app, "dist", output_name); if ( Object.keys(file_map).includes(output_name) || Object.keys(style_file_map).includes(output_name) ) { log_warn(`Duplicate output file ${output_name} generated from ${file}`); } if ([".css", ".scss", ".less", ".sass", ".styl"].includes(extension)) { style_file_map[output_name] = file; rtl_style_file_map[output_name.replace("/css/", "/css-rtl/")] = file; } else { file_map[output_name] = file; } } let build = build_files({ files: file_map, outdir: output_path, }); let style_build = build_style_files({ files: style_file_map, outdir: output_path, }); let rtl_style_build = build_style_files({ files: rtl_style_file_map, outdir: output_path, rtl_style: true, }); return Promise.all([build, style_build, rtl_style_build]); }); } function get_all_files_to_build(apps) { let include_patterns = []; let ignore_patterns = []; for (let app of apps) { let public_path = get_public_path(app); include_patterns.push( path.resolve(public_path, "**", "*.bundle.{js,ts,css,sass,scss,less,styl,jsx}") ); ignore_patterns.push( path.resolve(public_path, "node_modules"), path.resolve(public_path, "dist") ); } return { include_patterns, ignore_patterns, }; } function get_files_to_build(files) { // files: ['xhiveframework/website.bundle.js', 'xhiveerp/main.bundle.js'] let include_patterns = []; let ignore_patterns = []; for (let file of files) { let [app, bundle] = file.split("/"); let public_path = get_public_path(app); include_patterns.push(path.resolve(public_path, "**", bundle)); ignore_patterns.push( path.resolve(public_path, "node_modules"), path.resolve(public_path, "dist") ); } return { include_patterns, ignore_patterns, }; } function build_files({ files, outdir }) { let build_plugins = [vue(), html_plugin, build_cleanup_plugin, vue_style_plugin]; return esbuild.build(get_build_options(files, outdir, build_plugins)); } function build_style_files({ files, outdir, rtl_style = false }) { let plugins = []; if (rtl_style) { plugins.push(rtlcss); } let build_plugins = [ ignore_assets, build_cleanup_plugin, postCssPlugin({ plugins: plugins, sassOptions: sass_options, }), ]; plugins.push(require("autoprefixer")); return esbuild.build(get_build_options(files, outdir, build_plugins)); } function get_build_options(files, outdir, plugins) { return { entryPoints: files, entryNames: "[dir]/[name].[hash]", target: ["es2017"], outdir, sourcemap: true, bundle: true, metafile: true, minify: PRODUCTION, nodePaths: NODE_PATHS, define: { "process.env.NODE_ENV": JSON.stringify(PRODUCTION ? "production" : "development"), __VUE_OPTIONS_API__: JSON.stringify(true), __VUE_PROD_DEVTOOLS__: JSON.stringify(false), }, plugins: plugins, watch: get_watch_config(), }; } function get_watch_config() { if (WATCH_MODE) { return { async onRebuild(error, result) { if (error) { log_error("There was an error during rebuilding changes."); log(); log(chalk.dim(error.stack)); notify_redis({ error }); } else { let { new_assets_json, prev_assets_json } = await write_assets_json( result.metafile ); let changed_files; if (prev_assets_json) { changed_files = get_rebuilt_assets(prev_assets_json, new_assets_json); let timestamp = new Date().toLocaleTimeString(); let message = `${timestamp}: Compiled ${changed_files.length} files...`; log(chalk.yellow(message)); for (let filepath of changed_files) { let filename = path.basename(filepath); log(" " + filename); } log(); } notify_redis({ success: true, changed_files }); } }, }; } return null; } function log_built_assets(results) { let outputs = {}; for (const result of results) { outputs = Object.assign(outputs, result.metafile.outputs); } let column_widths = [60, 20]; cliui.div( { text: chalk.cyan.bold("File"), width: column_widths[0], }, { text: chalk.cyan.bold("Size"), width: column_widths[1], } ); cliui.div(""); let output_by_dist_path = {}; for (let outfile in outputs) { if (outfile.endsWith(".map")) continue; let data = outputs[outfile]; outfile = path.resolve(outfile); outfile = path.relative(assets_path, outfile); let filename = path.basename(outfile); let dist_path = outfile.replace(filename, ""); output_by_dist_path[dist_path] = output_by_dist_path[dist_path] || []; output_by_dist_path[dist_path].push({ name: filename, size: (data.bytes / 1000).toFixed(2) + " Kb", }); } for (let dist_path in output_by_dist_path) { let files = output_by_dist_path[dist_path]; cliui.div({ text: dist_path, width: column_widths[0], }); for (let i in files) { let file = files[i]; let branch = ""; if (i < files.length - 1) { branch = "├─ "; } else { branch = "└─ "; } let color = file.name.endsWith(".js") ? "green" : "blue"; cliui.div( { text: branch + chalk[color]("" + file.name), width: column_widths[0], }, { text: file.size, width: column_widths[1], } ); } cliui.div(""); } log(cliui.toString()); } // to store previous build's assets.json for comparison let prev_assets_json; let curr_assets_json; async function write_assets_json(metafile) { let rtl = false; prev_assets_json = curr_assets_json; let out = {}; for (let output in metafile.outputs) { let info = metafile.outputs[output]; let asset_path = "/" + path.relative(sites_path, output); if (info.entryPoint) { let key = path.basename(info.entryPoint); if (key.endsWith(".css") && asset_path.includes("/css-rtl/")) { rtl = true; key = `rtl_${key}`; } out[key] = asset_path; } } let assets_json_path = path.resolve(assets_path, `assets${rtl ? "-rtl" : ""}.json`); let assets_json; try { assets_json = await fs.promises.readFile(assets_json_path, "utf-8"); } catch (error) { assets_json = "{}"; } assets_json = JSON.parse(assets_json); // update with new values let new_assets_json = Object.assign({}, assets_json, out); curr_assets_json = new_assets_json; await fs.promises.writeFile(assets_json_path, JSON.stringify(new_assets_json, null, 4)); await update_assets_json_in_cache(); if (argv["save-metafiles"]) { // use current timestamp in readable formate as a suffix for filename let current_timestamp = new Date().getTime(); const metafile_name = `meta-${current_timestamp}.json`; await fs.promises.writeFile(`${metafile_name}`, JSON.stringify(metafile)); log(`Saved metafile as ${metafile_name}`); } return { new_assets_json, prev_assets_json, }; } async function update_assets_json_in_cache() { // update assets_json cache in redis, so that it can be read directly by python let client = get_redis_subscriber("redis_cache"); // handle error event to avoid printing stack traces try { await client.connect(); } catch (e) { log_warn("Cannot connect to redis_cache to update assets_json"); } client.del("assets_json", (err) => { client.unref(); }); } function run_build_command_for_apps(apps) { let cwd = process.cwd(); let { execSync } = require("child_process"); for (let app of apps) { if (app === "xhiveframework") continue; let root_app_path = path.resolve(get_app_path(app), ".."); let package_json = path.resolve(root_app_path, "package.json"); if (fs.existsSync(package_json)) { let { scripts } = require(package_json); if (scripts && scripts.build) { log("\nRunning build command for", chalk.bold(app)); process.chdir(root_app_path); execSync("yarn build", { encoding: "utf8", stdio: "inherit" }); } } } process.chdir(cwd); } async function notify_redis({ error, success, changed_files }) { // notify redis which in turns tells socketio to publish this to browser let subscriber = get_redis_subscriber("redis_queue"); try { await subscriber.connect(); } catch (e) { log_warn("Cannot connect to redis_queue for browser events"); } let payload = null; if (error) { let formatted = await esbuild.formatMessages(error.errors, { kind: "error", terminalWidth: 100, }); let stack = error.stack.replace(new RegExp(bench_path, "g"), ""); payload = { error, formatted, stack, }; } if (success) { payload = { success: true, changed_files, live_reload: argv["live-reload"], }; } await subscriber.publish( "events", JSON.stringify({ event: "build_event", message: payload, }) ); } async function open_in_editor() { let subscriber = get_redis_subscriber("redis_queue"); try { await subscriber.connect(); } catch (e) { log_warn("Cannot connect to redis_queue for open_in_editor events"); } subscriber.subscribe("open_in_editor", (file) => { file = JSON.parse(file); let file_path = path.resolve(file.file); log("Opening file in editor:", file_path); let launch = require("launch-editor"); launch(`${file_path}:${file.line}:${file.column}`); }); } function get_rebuilt_assets(prev_assets, new_assets) { let added_files = []; let old_files = Object.values(prev_assets); let new_files = Object.values(new_assets); for (let filepath of new_files) { if (!old_files.includes(filepath)) { added_files.push(filepath); } } return added_files; }