From 512ce5b978eadc4ad1d0b3d334a117e78b55ce4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 27 Apr 2026 21:03:09 -0300 Subject: [PATCH 1/4] util: create hex style cache and fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Guilherme Araújo --- lib/util.js | 67 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/lib/util.js b/lib/util.js index 9601593eaf404a..3b2db9dce5db0b 100644 --- a/lib/util.js +++ b/lib/util.js @@ -114,7 +114,11 @@ const kEscapeEnd = 'm'; const kDimCode = 2; const kBoldCode = 1; +// Close sequence for 24-bit foreground colors (reset to default foreground) +const kHexCloseSeq = kEscape + '39' + kEscapeEnd; + let styleCache; +const hexStyleCache = { __proto__: null }; function getStyleCache() { if (styleCache === undefined) { @@ -137,6 +141,25 @@ function getStyleCache() { return styleCache; } +/** + * Returns the cached ANSI escape sequences for a hex color. + * Computes and caches on first use to avoid repeated Buffer allocations. + * @param {string} hex A valid hex color string (#RGB or #RRGGBB) + * @returns {{openSeq: string, closeSeq: string}} + */ +function getHexStyle(hex) { + const cached = hexStyleCache[hex]; + if (cached !== undefined) return cached; + const { 0: r, 1: g, 2: b } = hexToRgb(hex); + const style = { + __proto__: null, + openSeq: kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd, + closeSeq: kHexCloseSeq, + }; + hexStyleCache[hex] = style; + return style; +} + function replaceCloseCode(str, closeSeq, openSeq, keepClose) { const closeLen = closeSeq.length; let index = str.indexOf(closeSeq); @@ -163,15 +186,6 @@ function replaceCloseCode(str, closeSeq, openSeq, keepClose) { // Matches #RGB or #RRGGBB const hexColorRegExp = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; -/** - * Validates whether a string is a valid hex color code. - * @param {string} hex The hex string to validate (e.g., '#fff' or '#ffffff') - * @returns {boolean} True if valid hex color, false otherwise - */ -function isValidHexColor(hex) { - return typeof hex === 'string' && RegExpPrototypeExec(hexColorRegExp, hex) !== null; -} - /** * Parses a hex color string into RGB components. * Supports both 3-digit (#RGB) and 6-digit (#RRGGBB) formats. @@ -225,6 +239,17 @@ function styleText(format, text, options) { const processed = replaceCloseCode(text, style.closeSeq, style.openSeq, style.keepClose); return style.openSeq + processed + style.closeSeq; } + + if (format[0] === '#') { + let hexStyle = hexStyleCache[format]; + if (hexStyle === undefined && RegExpPrototypeExec(hexColorRegExp, format) !== null) { + hexStyle = getHexStyle(format); + } + if (hexStyle !== undefined) { + const processed = replaceCloseCode(text, hexStyle.closeSeq, hexStyle.openSeq, false); + return hexStyle.openSeq + processed + hexStyle.closeSeq; + } + } } validateString(text, 'text'); @@ -255,24 +280,24 @@ function styleText(format, text, options) { for (const key of formatArray) { if (key === 'none') continue; - if (isValidHexColor(key)) { + if (typeof key === 'string' && key[0] === '#') { + let hexStyle = hexStyleCache[key]; + if (hexStyle === undefined) { + if (RegExpPrototypeExec(hexColorRegExp, key) === null) { + throw new ERR_INVALID_ARG_VALUE('format', key, + 'must be a valid hex color (#RGB or #RRGGBB)'); + } + hexStyle = getHexStyle(key); + } if (skipColorize) continue; - const { 0: r, 1: g, 2: b } = hexToRgb(key); - const openSeq = kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd; - const closeSeq = kEscape + '39' + kEscapeEnd; - openCodes += openSeq; - closeCodes = closeSeq + closeCodes; - processedText = replaceCloseCode(processedText, closeSeq, openSeq, false); + openCodes += hexStyle.openSeq; + closeCodes = hexStyle.closeSeq + closeCodes; + processedText = replaceCloseCode(processedText, hexStyle.closeSeq, hexStyle.openSeq, false); continue; } const style = cache[key]; if (style === undefined) { - // Check if it looks like an invalid hex color (starts with #) - if (typeof key === 'string' && key[0] === '#') { - throw new ERR_INVALID_ARG_VALUE('format', key, - 'must be a valid hex color (#RGB or #RRGGBB)'); - } validateOneOf(key, 'format', ObjectGetOwnPropertyNames(inspect.colors)); } openCodes += style.openSeq; From bcf6f74a3320b268a2641ef35e9a7c6c95dda097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Tue, 28 Apr 2026 11:33:55 -0300 Subject: [PATCH 2/4] util: skip getHexStyle() when colorization is disabled --- lib/util.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/util.js b/lib/util.js index 3b2db9dce5db0b..34751d25aa5aa6 100644 --- a/lib/util.js +++ b/lib/util.js @@ -287,9 +287,11 @@ function styleText(format, text, options) { throw new ERR_INVALID_ARG_VALUE('format', key, 'must be a valid hex color (#RGB or #RRGGBB)'); } + if (skipColorize) continue; hexStyle = getHexStyle(key); + } else if (skipColorize) { + continue; } - if (skipColorize) continue; openCodes += hexStyle.openSeq; closeCodes = hexStyle.closeSeq + closeCodes; processedText = replaceCloseCode(processedText, hexStyle.closeSeq, hexStyle.openSeq, false); From 5b9ea9dbbae728a5182b58e1da011d7f553727e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Fri, 8 May 2026 19:41:46 -0300 Subject: [PATCH 3/4] benchmark: create rotating hex benchmark --- benchmark/util/style-text.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/benchmark/util/style-text.js b/benchmark/util/style-text.js index f04a26646e052d..c50a225fd39331 100644 --- a/benchmark/util/style-text.js +++ b/benchmark/util/style-text.js @@ -5,9 +5,22 @@ const common = require('../common.js'); const { styleText } = require('node:util'); const assert = require('node:assert'); +// 1000 distinct hex colors to exercise the cache under high-miss conditions. +// Spread evenly across hue space so colors are valid and maximally varied. +const kHexColorCount = 1000; +const toHex = (n) => n.toString(16).padStart(2, '0'); +const hexColors = Array.from({ length: kHexColorCount }, (_, i) => { + const r = (i * 37) & 0xff; + const g = (i * 73) & 0xff; + const b = (i * 137) & 0xff; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +}); + const bench = common.createBenchmark(main, { messageType: ['string', 'number', 'boolean', 'invalid'], - format: ['red', 'italic', 'invalid', '#ff0000'], + // '#rotating' cycles through kHexColorCount distinct colors to simulate + // the high-miss-rate / large-cache scenario (e.g. user-randomised colors). + format: ['red', 'italic', 'invalid', '#ff0000', '#rotating'], validateStream: [1, 0], n: [1e3], }); @@ -31,9 +44,10 @@ function main({ messageType, format, validateStream, n }) { bench.start(); for (let i = 0; i < n; i++) { + const fmt = format === '#rotating' ? hexColors[i % kHexColorCount] : format; let colored = ''; try { - colored = styleText(format, str, { validateStream }); + colored = styleText(fmt, str, { validateStream }); assert.ok(colored); // Attempt to avoid dead-code elimination } catch { // eslint-disable no-empty From f4f71de3bafa54a5f9f5e2900bfea99c5be9d37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sat, 9 May 2026 11:27:22 -0300 Subject: [PATCH 4/4] util: implement fifo cache with safemap --- lib/util.js | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/lib/util.js b/lib/util.js index 34751d25aa5aa6..b9ce892f96bd3f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -38,6 +38,7 @@ const { ObjectValues, ReflectApply, RegExpPrototypeExec, + SafeMap, StringPrototypeSlice, StringPrototypeToWellFormed, } = primordials; @@ -118,7 +119,29 @@ const kBoldCode = 1; const kHexCloseSeq = kEscape + '39' + kEscapeEnd; let styleCache; -const hexStyleCache = { __proto__: null }; + +const kHexStyleCacheMax = 256; + +class HexStyleFIFOCache { + #cache = new SafeMap(); + + get(key) { + return this.#cache.get(key); + } + + set(key, value) { + if (this.#cache.size >= kHexStyleCacheMax) + this.#cache.delete(this.#cache.keys().next().value); + this.#cache.set(key, value); + } +} + +let hexStyleCache; + +function getHexStyleCache() { + hexStyleCache ??= new HexStyleFIFOCache(); + return hexStyleCache; +} function getStyleCache() { if (styleCache === undefined) { @@ -148,7 +171,8 @@ function getStyleCache() { * @returns {{openSeq: string, closeSeq: string}} */ function getHexStyle(hex) { - const cached = hexStyleCache[hex]; + const cache = getHexStyleCache(); + const cached = cache.get(hex); if (cached !== undefined) return cached; const { 0: r, 1: g, 2: b } = hexToRgb(hex); const style = { @@ -156,7 +180,7 @@ function getHexStyle(hex) { openSeq: kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd, closeSeq: kHexCloseSeq, }; - hexStyleCache[hex] = style; + cache.set(hex, style); return style; } @@ -241,7 +265,7 @@ function styleText(format, text, options) { } if (format[0] === '#') { - let hexStyle = hexStyleCache[format]; + let hexStyle = getHexStyleCache().get(format); if (hexStyle === undefined && RegExpPrototypeExec(hexColorRegExp, format) !== null) { hexStyle = getHexStyle(format); } @@ -281,7 +305,7 @@ function styleText(format, text, options) { if (key === 'none') continue; if (typeof key === 'string' && key[0] === '#') { - let hexStyle = hexStyleCache[key]; + let hexStyle = getHexStyleCache().get(key); if (hexStyle === undefined) { if (RegExpPrototypeExec(hexColorRegExp, key) === null) { throw new ERR_INVALID_ARG_VALUE('format', key,