logger/lib/writers/Console.js

  1. import process from "node:process";
  2. import {chalkStderr as chalk} from "chalk";
  3. import figures from "figures";
  4. import {MultiBar} from "cli-progress";
  5. import Logger from "../loggers/Logger.js";
  6. /**
  7. * Standard handler for events emitted by @ui5/logger modules. Writes messages to
  8. * [<code>process.stderr</code>]{@link https://nodejs.org/api/process.html#processstderr} stream
  9. * and renders a progress bar for UI5 Tooling build processes.
  10. * <br><br>
  11. * The progress bar is only used in interactive terminals. If verbose logging is enabled, the progress
  12. * bar is disabled.
  13. *
  14. * @public
  15. * @class
  16. * @alias @ui5/logger/writers/Console
  17. */
  18. class Console {
  19. #projectMetadata = new Map();
  20. #progressBarContainer;
  21. #progressBar;
  22. #progressProjectWeight;
  23. constructor() {
  24. this._handleLogEvent = this.#handleLogEvent.bind(this);
  25. this._handleBuildStatusEvent = this.#handleBuildStatusEvent.bind(this);
  26. this._handleProjectBuildStatusEvent = this.#handleProjectBuildStatusEvent.bind(this);
  27. this._handleBuildMetadataEvent = this.#handleBuildMetadataEvent.bind(this);
  28. this._handleProjectBuildMetadataEvent = this.#handleProjectBuildMetadataEvent.bind(this);
  29. this._handleStop = this.disable.bind(this);
  30. }
  31. /**
  32. * Attaches all event listeners and starts writing to output stream
  33. *
  34. * @public
  35. */
  36. enable() {
  37. process.on("ui5.log", this._handleLogEvent);
  38. process.on("ui5.build-metadata", this._handleBuildMetadataEvent);
  39. process.on("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
  40. process.on("ui5.build-status", this._handleBuildStatusEvent);
  41. process.on("ui5.project-build-status", this._handleProjectBuildStatusEvent);
  42. process.on("ui5.log.stop-console", this._handleStop);
  43. }
  44. /**
  45. * Detaches all event listeners and stops writing to output stream
  46. *
  47. * @public
  48. */
  49. disable() {
  50. process.off("ui5.log", this._handleLogEvent);
  51. process.off("ui5.build-metadata", this._handleBuildMetadataEvent);
  52. process.off("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
  53. process.off("ui5.build-status", this._handleBuildStatusEvent);
  54. process.off("ui5.project-build-status", this._handleProjectBuildStatusEvent);
  55. process.off("ui5.log.stop-console", this._handleStop);
  56. if (this.#progressBarContainer) {
  57. this.#progressBar.stop();
  58. this.#progressBarContainer.stop(); // Will fire internal stop event
  59. }
  60. }
  61. /*
  62. * Progress bar is only required when building projects. So we create it lazily
  63. */
  64. _getProgressBar() {
  65. // Do not use a progress bar if there is no text terminal attached or verbose logging is enabled
  66. // * If log output is piped to a static output (= no TTY), no progress bar should be rendered
  67. // * Since logging through the progress bar is asynchronous (controlled by the FPS setting),
  68. // exceptions might lead to log messages being dropped. Therefore do not use a progress bar
  69. // for verbose logging
  70. // * If log-level is set to "silent", we should never render a progress bar
  71. if (process.stderr.isTTY !== true || Logger.isLevelEnabled("verbose") || Logger.getLevel() === "silent") {
  72. return null;
  73. }
  74. if (!this.#progressBarContainer) {
  75. // We use a "MultiBar" instance even though we intend to only render a single progress bar
  76. // This is because only MultiBar provides a "log" method as of today
  77. this.#progressBarContainer = new MultiBar({
  78. format: `{bar} {message}`,
  79. barsize: 20,
  80. linewrap: true,
  81. emptyOnZero: 0,
  82. hideCursor: true,
  83. // FPS also controls how fast a log message will be rendered above the progress bar
  84. fps: 120,
  85. // Disable progress bar explicitly for non-TTY, even though this is already checked above
  86. noTTYOutput: false,
  87. // Graceful exit is required to ensure all terminal settings (e.g. hideCursor)
  88. // are restored on SIGINT
  89. gracefulExit: true,
  90. // Required to prevent flickering when logging
  91. forceRedraw: true,
  92. clearOnComplete: true,
  93. stopOnComplete: true,
  94. barCompleteChar: figures.square,
  95. barIncompleteChar: figures.squareLightShade,
  96. });
  97. // Initialize empty progress bar to enable logging through the multibar instance
  98. this.#progressBar = this.#progressBarContainer.create(0, 0, {message: ""});
  99. this.#progressBarContainer.on("stop", () => {
  100. // Progress bar has finished and will remove itself from the output.
  101. // Therefore, further logging needs to be done directly to process.stderr. Otherwise it would disappear.
  102. // Therefore, we de-reference all instances now
  103. this.#progressBarContainer = null;
  104. this.#progressBar = null;
  105. });
  106. }
  107. return this.#progressBar;
  108. }
  109. #writeMessage(level, message) {
  110. if (!Logger.isLevelEnabled(level)) {
  111. return;
  112. }
  113. const levelPrefix = this.#getLevelPrefix(level);
  114. const msg = `${levelPrefix} ${message}\n`;
  115. if (this.#progressBarContainer) {
  116. // If a progress bar is in use, we have to log through it's API
  117. // cli-progress requires full control of the stderr output to ensure correct rendering
  118. this.#progressBarContainer.log(msg);
  119. } else {
  120. process.stderr.write(msg);
  121. }
  122. }
  123. #handleLogEvent({level, message, moduleName}) {
  124. this.#writeMessage(level, `${chalk.blue(moduleName)} ${message}`);
  125. }
  126. #handleBuildMetadataEvent({projectsToBuild}) {
  127. projectsToBuild.forEach((projectName) => {
  128. this.#projectMetadata.set(projectName, {
  129. buildStarted: false,
  130. buildSkipped: false,
  131. buildEnded: false,
  132. buildStartIndex: null,
  133. projectTasks: new Map(),
  134. });
  135. });
  136. this.#updateProgressBarTotal();
  137. }
  138. #handleProjectBuildMetadataEvent({tasksToRun, projectName, projectType}) {
  139. const projectMetadata = this.#getProjectMetadata(projectName);
  140. tasksToRun.forEach((taskName) => {
  141. projectMetadata.projectTasks.set(taskName, {
  142. executionStarted: false,
  143. executionEnded: false,
  144. executionStartIndex: null,
  145. });
  146. });
  147. this.#updateProgressBarTotal();
  148. }
  149. #getProjectMetadata(projectName) {
  150. const projectMetadata = this.#projectMetadata.get(projectName);
  151. if (!projectMetadata) {
  152. throw new Error(`writers/Console: Unknown project ${projectName}`);
  153. }
  154. return projectMetadata;
  155. }
  156. #updateProgressBarTotal() {
  157. let numberOfTasks = 0;
  158. this.#projectMetadata.forEach(({projectTasks}) => {
  159. numberOfTasks += projectTasks.size;
  160. });
  161. // Project progress should weigh more than single task progress
  162. // This is proportional to the number of projects (since that also multiplies the number of tasks)
  163. this.#progressProjectWeight = this.#projectMetadata.size;
  164. this._getProgressBar()?.setTotal(
  165. (this.#progressProjectWeight * this.#projectMetadata.size) + numberOfTasks);
  166. }
  167. #handleBuildStatusEvent({level, projectName, projectType, status}) {
  168. const projectMetadata = this.#getProjectMetadata(projectName);
  169. if (projectMetadata.buildStartIndex === null) {
  170. let nextIdx = 1;
  171. this.#projectMetadata.forEach((metadata) => {
  172. if (metadata.buildStartIndex !== null && metadata.buildStartIndex >= nextIdx) {
  173. nextIdx = metadata.buildStartIndex + 1;
  174. }
  175. });
  176. projectMetadata.buildStartIndex = nextIdx;
  177. }
  178. const buildIndex = `Project ${projectMetadata.buildStartIndex} of ${this.#projectMetadata.size}`;
  179. let message;
  180. switch (status) {
  181. case "project-build-start":
  182. if (projectMetadata.buildEnded) {
  183. throw new Error(
  184. `writers/Console: Unexpected project-build-start event for project ${projectName}. ` +
  185. `Project build already ended`);
  186. }
  187. if (projectMetadata.buildStarted) {
  188. throw new Error(
  189. `writers/Console: Unexpected duplicate project-build-start event for project ${projectName}`);
  190. }
  191. if (projectMetadata.buildSkipped) {
  192. throw new Error(
  193. `writers/Console: Unexpected project-build-start event for project ${projectName}. ` +
  194. `Project build already skipped`);
  195. }
  196. projectMetadata.buildStarted = true;
  197. message = `${chalk.blue(figures.pointer)} ` +
  198. `Building ${projectType} project ${chalk.bold(projectName)}...`;
  199. // Update progress bar message with current project
  200. this._getProgressBar()?.update({
  201. message: `${figures.pointer} Building ${projectType} project ${projectName}...`
  202. });
  203. break;
  204. case "project-build-end":
  205. if (projectMetadata.buildEnded) {
  206. throw new Error(
  207. `writers/Console: Unexpected duplicate project-build-end event for project ${projectName}`);
  208. }
  209. if (projectMetadata.buildSkipped) {
  210. throw new Error(
  211. `writers/Console: Unexpected project-build-end event for project ${projectName}. ` +
  212. `Project build already skipped`);
  213. }
  214. if (!projectMetadata.buildStarted) {
  215. throw new Error(
  216. `writers/Console: Unexpected project-build-end event for project ${projectName}. ` +
  217. `No corresponding project-build-start event handled`);
  218. }
  219. projectMetadata.buildEnded = true;
  220. message = `${chalk.green(figures.tick)} ` +
  221. `Finished building ${projectType} project ${chalk.bold(projectName)}`;
  222. // Update progress bar (if used)
  223. this._getProgressBar()?.increment(this.#progressProjectWeight);
  224. break;
  225. case "project-build-skip":
  226. if (projectMetadata.buildSkipped) {
  227. throw new Error(
  228. `writers/Console: Unexpected duplicate project-build-skip event for project ${projectName}`);
  229. }
  230. if (projectMetadata.buildEnded) {
  231. throw new Error(
  232. `writers/Console: Unexpected project-build-skip event for project ${projectName}. ` +
  233. `Project build already ended`);
  234. }
  235. if (projectMetadata.buildStarted) {
  236. throw new Error(
  237. `writers/Console: Unexpected project-build-skip event for project ${projectName}. ` +
  238. `Project build already started`);
  239. }
  240. projectMetadata.buildSkipped = true;
  241. message = `${chalk.yellow(figures.tick)} ` +
  242. `Skipping build of ${projectType} project ${chalk.bold(projectName)}`;
  243. // Update progress bar (if used)
  244. // All tasks of this projects are completed
  245. this._getProgressBar()?.increment(this.#progressProjectWeight + projectMetadata.projectTasks.size);
  246. break;
  247. default:
  248. this.#writeMessage("verbose",
  249. `writers/Console: Received unknown build-status ${status} for project ${projectName}`);
  250. return;
  251. }
  252. this.#writeMessage(level, `${chalk.grey(buildIndex)}: ${message}`);
  253. }
  254. #handleProjectBuildStatusEvent({level, projectName, projectType, taskName, status}) {
  255. const {projectTasks} = this.#getProjectMetadata(projectName);
  256. const taskMetadata = projectTasks.get(taskName);
  257. if (!taskMetadata) {
  258. throw new Error(`writers/Console: Unknown task ${taskName} for project ${projectName}`);
  259. }
  260. if (taskMetadata.executionStartIndex === null) {
  261. let nextIdx = 1;
  262. projectTasks.forEach((metadata) => {
  263. if (metadata.executionStartIndex !== null && metadata.executionStartIndex >= nextIdx) {
  264. nextIdx = metadata.executionStartIndex + 1;
  265. }
  266. });
  267. taskMetadata.executionStartIndex = nextIdx;
  268. }
  269. let taskIndex = "";
  270. if (Logger.isLevelEnabled("verbose")) {
  271. taskIndex = chalk.grey(`Task ${taskMetadata.executionStartIndex} of ${projectTasks.size} `);
  272. }
  273. let message;
  274. switch (status) {
  275. case "task-start":
  276. if (taskMetadata.executionEnded) {
  277. throw new Error(
  278. `writers/Console: Unexpected task-start event for project ${projectName}, task ${taskName}. ` +
  279. `Task execution already ended`);
  280. }
  281. if (taskMetadata.executionStarted) {
  282. throw new Error(`writers/Console: Unexpected duplicate task-start event ` +
  283. `for project ${projectName}, task ${taskName}`);
  284. }
  285. taskMetadata.executionStarted = true;
  286. message = `${chalk.blue(figures.pointerSmall)} Running task ${chalk.bold(taskName)}...`;
  287. break;
  288. case "task-end":
  289. if (taskMetadata.executionEnded) {
  290. throw new Error(`writers/Console: ` +
  291. `Unexpected duplicate task-end event for project ${projectName}, task ${taskName}`);
  292. }
  293. if (!taskMetadata.executionStarted) {
  294. throw new Error(
  295. `writers/Console: Unexpected task-end event for project ${projectName}, task ${taskName}. ` +
  296. `No corresponding task-start event handled`);
  297. }
  298. taskMetadata.executionEnded = true;
  299. message = `${chalk.green(figures.tick)} Finished task ${chalk.bold(taskName)}`;
  300. // Update progress bar (if used)
  301. this._getProgressBar()?.increment(1);
  302. break;
  303. default:
  304. this.#writeMessage("verbose",
  305. `writers/Console: Received unknown project-build-status ${status} for project ${projectName}`);
  306. return;
  307. }
  308. this.#writeMessage(level, `${chalk.blue(`${(projectName)}`)} ${taskIndex}${message}`);
  309. }
  310. #getLevelPrefix(level) {
  311. switch (level) {
  312. case "silly":
  313. return chalk.inverse(level);
  314. case "verbose":
  315. return chalk.cyan("verb");
  316. case "perf":
  317. return chalk.bgYellow.red(level);
  318. case "info":
  319. return chalk.green(level);
  320. case "warn":
  321. return chalk.yellow(level);
  322. case "error":
  323. return chalk.bgRed.white(level);
  324. // Log level silent does not produce messages
  325. default:
  326. return level;
  327. }
  328. }
  329. /**
  330. * Creates a new instance and subscribes it to all events
  331. *
  332. * @public
  333. */
  334. static init() {
  335. const cH = new Console();
  336. cH.enable();
  337. return cH;
  338. }
  339. static stop() {
  340. process.emit("ui5.log.stop-console");
  341. }
  342. }
  343. export default Console;