From 7ef196bde0783183d1f5fbf47f4f2fd119bccb05 Mon Sep 17 00:00:00 2001 From: yujeong-jeon Date: Tue, 18 Nov 2025 15:04:53 +0900 Subject: [PATCH 1/6] [#202] safe-html-react-parser: Implement custom server-side DOMPurify wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace isomorphic-dompurify dependency with a custom implementation using dompurify + jsdom directly for better control and optimization. Changes: - Add src/utils/dompurify.ts with OptimizedDOMPurify class - Implement LRU cache (default 100 entries) for sanitized HTML - Add periodic JSDOM instance recreation (default every 1000 calls) - Update dependencies: isomorphic-dompurify → dompurify + jsdom Benefits: - Better memory management with configurable cache and recreation interval - Improved performance through result caching - More control over server-side sanitization behavior --- packages/safe-html-react-parser/package.json | 7 +- packages/safe-html-react-parser/src/index.ts | 15 +- .../src/utils/dompurify.ts | 139 ++++++++++++++++++ pnpm-lock.yaml | 109 +++++++------- 4 files changed, 204 insertions(+), 66 deletions(-) create mode 100644 packages/safe-html-react-parser/src/utils/dompurify.ts diff --git a/packages/safe-html-react-parser/package.json b/packages/safe-html-react-parser/package.json index e245b393..4339f05a 100644 --- a/packages/safe-html-react-parser/package.json +++ b/packages/safe-html-react-parser/package.json @@ -19,10 +19,12 @@ ], "author": "@NaverPayDev/frontend", "dependencies": { + "dompurify": "^3.3.0", "html-react-parser": "^5.2.7", - "isomorphic-dompurify": "^2.30.1" + "jsdom": "^27.2.0" }, "devDependencies": { + "@types/jsdom": "^27.0.0", "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", "react": "0.14 || 15 || 16 || 17 || 18 || 19" }, @@ -32,7 +34,8 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "npm run clean && vite build" + "build": "npm run clean && vite build", + "test:memory": "vitest run --watch=false" }, "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", diff --git a/packages/safe-html-react-parser/src/index.ts b/packages/safe-html-react-parser/src/index.ts index ec9b82dd..ae6cb711 100644 --- a/packages/safe-html-react-parser/src/index.ts +++ b/packages/safe-html-react-parser/src/index.ts @@ -3,11 +3,12 @@ * Utilizes html-react-parser with DOMPurify for safe HTML parsing */ import * as htmlReactParser from 'html-react-parser' -import DOMPurify from 'isomorphic-dompurify' + +import {sanitizeHtml, type SanitizeConfig} from './utils/dompurify' import type {DOMNode, HTMLReactParserOptions} from 'html-react-parser' -// html-react-parser가 esm에서 cjs 모듈을 re-export 하는 문제 처리 +// Solving the issue of html-react-parser re-exporting cjs modules in esm // In CJS: htmlReactParser.default.default is the actual function // In ESM: htmlReactParser.default is the function const parse = ((htmlReactParser as any).default?.default || @@ -18,14 +19,14 @@ export interface SafeParseOptions extends HTMLReactParserOptions { /** * DOMPurify Options */ - sanitizeConfig?: DOMPurify.Config + sanitizeConfig?: SanitizeConfig /** * Custom tag preservation option (temporary conversion before and after DOMPurify processing) */ preserveCustomTags?: string[] } -export const DEFAULT_SANITIZE_CONFIG: DOMPurify.Config = { +export const DEFAULT_SANITIZE_CONFIG: SanitizeConfig = { ALLOWED_TAGS: [ 'p', 'br', @@ -73,7 +74,11 @@ export function safeParse(htmlString: string, options: SafeParseOptions = {}) { htmlString, ) || htmlString - const sanitizedHtml = DOMPurify.sanitize(processedHtml, sanitizeConfig) + const sanitizedHtml = sanitizeHtml(processedHtml, sanitizeConfig) + + if (!sanitizedHtml) { + return null + } return parse(sanitizedHtml, { ...parserOptions, diff --git a/packages/safe-html-react-parser/src/utils/dompurify.ts b/packages/safe-html-react-parser/src/utils/dompurify.ts new file mode 100644 index 00000000..e8039607 --- /dev/null +++ b/packages/safe-html-react-parser/src/utils/dompurify.ts @@ -0,0 +1,139 @@ +import createDOMPurify from 'dompurify' +import {JSDOM} from 'jsdom' + +export interface Options { + /** + * Interval for recreating the DOMPurify instance to prevent memory leaks + * Default is 1000 sanitization calls + */ + recreateInterval?: number + /** + * Enable caching of sanitized results to improve performance + * Default is true + */ + enableCache?: boolean + /** + * Maximum size of the cache + * Default is 100 entries + */ + maxCacheSize?: number +} + +export type DomPurify = ReturnType + +type SanitizeParams = Parameters +export type DirtyHtml = SanitizeParams[0] +export type SanitizeConfig = SanitizeParams[1] + +class OptimizedDOMPurify { + recreateInterval: number + jsdom: JSDOM | null + purify: ReturnType | null + callCount: number + enableCache: boolean + cache: Map | null + maxCacheSize: number + constructor( + options: Options = { + enableCache: true, + }, + ) { + this.recreateInterval = options?.recreateInterval || 1000 + this.enableCache = !!options?.enableCache + this.cache = this.enableCache ? new Map() : null + this.maxCacheSize = options?.maxCacheSize || 100 + + this.jsdom = null + this.purify = null + this.callCount = 0 + + this.initialize() + } + + initialize() { + // Cleanup previous instance + if (this.jsdom?.window) { + try { + const doc = this.jsdom.window.document + if (doc.body) { + doc.body.innerHTML = '' + } + if (doc.head) { + doc.head.innerHTML = '' + } + while (doc.firstChild) { + doc.removeChild(doc.firstChild) + } + } catch { + // ignore cleanup errors + } + this.jsdom.window.close() + } + + this.purify = null + this.jsdom = null + + if (global.gc && typeof global.gc === 'function') { + global.gc() + } + + this.jsdom = new JSDOM('') + this.purify = createDOMPurify(this.jsdom.window) + this.callCount = 0 + + if (this.cache) { + this.cache.clear() + } + } + + sanitize(dirty: DirtyHtml, config?: SanitizeConfig) { + if (this.enableCache && !config && this.cache) { + if (this.cache.has(dirty)) { + return this.cache.get(dirty) + } + } + + const cleanHtml = this.purify?.sanitize(dirty, config) + + if (this.enableCache && !config && this.cache) { + if (this.cache.size >= this.maxCacheSize) { + const firstKey = this.cache.keys().next().value + firstKey && this.cache.delete(firstKey) + } + cleanHtml && this.cache.set(dirty, cleanHtml) + } + + this.callCount++ + if (this.callCount >= this.recreateInterval) { + this.initialize() + } + + return cleanHtml + } + + cleanup() { + if (this.jsdom?.window) { + this.jsdom.window.close() + } + if (this.cache) { + this.cache.clear() + } + this.jsdom = null + this.purify = null + } +} + +let instance: OptimizedDOMPurify | null = null + +function getSanitizer(options?: Options) { + if (!instance) { + instance = new OptimizedDOMPurify(options) + } + return instance +} + +export function sanitizeHtml(dirty: DirtyHtml, config?: SanitizeConfig) { + const isClientSide = typeof window !== 'undefined' + const sanitizer = isClientSide ? createDOMPurify : getSanitizer() + return sanitizer.sanitize(dirty, config) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afff3fae..6ef14e2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,13 +249,19 @@ importers: packages/safe-html-react-parser: dependencies: + dompurify: + specifier: ^3.3.0 + version: 3.3.0 html-react-parser: specifier: ^5.2.7 version: 5.2.7(@types/react@18.3.20)(react@18.3.1) - isomorphic-dompurify: - specifier: ^2.30.1 - version: 2.30.1(postcss@8.5.3) + jsdom: + specifier: ^27.2.0 + version: 27.2.0(postcss@8.5.3) devDependencies: + '@types/jsdom': + specifier: ^27.0.0 + version: 27.0.0 '@types/react': specifier: 0.14 || 15 || 16 || 17 || 18 || 19 version: 18.3.20 @@ -293,7 +299,7 @@ importers: version: 22.14.0 vitest: specifier: ^3.1.1 - version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.0.1(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1) + version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.2.0(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1) packages/utils: devDependencies: @@ -305,7 +311,7 @@ importers: version: 17.4.4 vitest: specifier: ^3.1.1 - version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.0.1(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1) + version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.2.0(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1) packages/vanilla-store: devDependencies: @@ -323,7 +329,7 @@ importers: version: 18.3.1 vitest: specifier: ^3.1.1 - version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.0.1(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1) + version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.2.0(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1) packages: @@ -331,6 +337,9 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} + '@acemir/cssom@0.9.23': + resolution: {integrity: sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==} + '@algolia/autocomplete-core@1.9.3': resolution: {integrity: sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==} @@ -409,8 +418,8 @@ packages: '@asamuzakjp/css-color@4.0.5': resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} - '@asamuzakjp/dom-selector@6.7.2': - resolution: {integrity: sha512-ccKogJI+0aiDhOahdjANIc9SDixSud1gbwdVrhn7kMopAtLXqsz9MKmQQtIl6Y5aC2IYq+j4dz/oedL2AVMmVQ==} + '@asamuzakjp/dom-selector@6.7.4': + resolution: {integrity: sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==} '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} @@ -3335,6 +3344,9 @@ packages: '@types/jest@29.5.12': resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + '@types/jsdom@27.0.0': + resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3454,6 +3466,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -4631,8 +4646,8 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - cssstyle@5.3.1: - resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==} + cssstyle@5.3.3: + resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} engines: {node: '>=20'} csstype@3.1.3: @@ -6400,10 +6415,6 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} - isomorphic-dompurify@2.30.1: - resolution: {integrity: sha512-VJFbthRrns7BE+q3qSUJ5zxGNjuq4FqiaWXKCwnMoJbumnoQJoeOeOzP/oejKLPPtENckLWoDxGQiv5OkEFC+Q==} - engines: {node: '>=20.19.5'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -6613,9 +6624,9 @@ packages: '@babel/preset-env': optional: true - jsdom@27.0.1: - resolution: {integrity: sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==} - engines: {node: '>=20'} + jsdom@27.2.0: + resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -8574,9 +8585,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - rtl-detect@1.1.2: resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} @@ -10164,18 +10172,6 @@ packages: utf-8-validate: optional: true - ws@8.16.0: - resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -10273,6 +10269,8 @@ snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} + '@acemir/cssom@0.9.23': {} + '@algolia/autocomplete-core@1.9.3(@algolia/client-search@4.23.3)(algoliasearch@4.23.3)(search-insights@2.13.0)': dependencies: '@algolia/autocomplete-plugin-algolia-insights': 1.9.3(@algolia/client-search@4.23.3)(algoliasearch@4.23.3)(search-insights@2.13.0) @@ -10394,7 +10392,7 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 11.2.2 - '@asamuzakjp/dom-selector@6.7.2': + '@asamuzakjp/dom-selector@6.7.4': dependencies: '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 @@ -14577,7 +14575,7 @@ snapshots: util: 0.12.5 util-deprecate: 1.0.2 watchpack: 2.4.0 - ws: 8.16.0 + ws: 8.18.3 transitivePeerDependencies: - bufferutil - encoding @@ -15169,6 +15167,12 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/jsdom@27.0.0': + dependencies: + '@types/node': 22.14.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -15292,6 +15296,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -16711,7 +16717,7 @@ snapshots: dependencies: css-tree: 2.2.1 - cssstyle@5.3.1(postcss@8.5.3): + cssstyle@5.3.3(postcss@8.5.3): dependencies: '@asamuzakjp/css-color': 4.0.5 '@csstools/css-syntax-patches-for-csstree': 1.0.14(postcss@8.5.3) @@ -18792,17 +18798,6 @@ snapshots: isobject@3.0.1: {} - isomorphic-dompurify@2.30.1(postcss@8.5.3): - dependencies: - dompurify: 3.3.0 - jsdom: 27.0.1(postcss@8.5.3) - transitivePeerDependencies: - - bufferutil - - canvas - - postcss - - supports-color - - utf-8-validate - istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -19240,10 +19235,11 @@ snapshots: transitivePeerDependencies: - supports-color - jsdom@27.0.1(postcss@8.5.3): + jsdom@27.2.0(postcss@8.5.3): dependencies: - '@asamuzakjp/dom-selector': 6.7.2 - cssstyle: 5.3.1(postcss@8.5.3) + '@acemir/cssom': 0.9.23 + '@asamuzakjp/dom-selector': 6.7.4 + cssstyle: 5.3.3(postcss@8.5.3) data-urls: 6.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 @@ -19251,7 +19247,6 @@ snapshots: https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 parse5: 8.0.0 - rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.0 @@ -21578,8 +21573,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.8 fsevents: 2.3.3 - rrweb-cssom@0.8.0: {} - rtl-detect@1.1.2: {} rtlcss@4.1.1: @@ -22981,7 +22974,7 @@ snapshots: sass-embedded: 1.85.1 terser: 5.28.1 - vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.0.1(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1): + vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@27.2.0(postcss@8.5.3))(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1): dependencies: '@vitest/expect': 3.1.1 '@vitest/mocker': 3.1.1(vite@6.2.0(@types/node@22.14.0)(jiti@1.21.0)(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1)) @@ -23007,7 +23000,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 22.14.0 happy-dom: 17.4.4 - jsdom: 27.0.1(postcss@8.5.3) + jsdom: 27.2.0(postcss@8.5.3) transitivePeerDependencies: - jiti - less @@ -23131,7 +23124,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 5.3.4(webpack@5.90.3(@swc/core@1.4.15)(esbuild@0.25.0)) - ws: 8.16.0 + ws: 8.18.3 optionalDependencies: webpack: 5.90.3(@swc/core@1.4.15)(esbuild@0.25.0) transitivePeerDependencies: @@ -23171,7 +23164,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 5.3.4(webpack@5.90.3(@swc/core@1.4.15)(esbuild@0.18.20)) - ws: 8.16.0 + ws: 8.18.3 optionalDependencies: webpack: 5.90.3(@swc/core@1.4.15)(esbuild@0.18.20) transitivePeerDependencies: @@ -23212,7 +23205,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 5.3.4(webpack@5.90.3(@swc/core@1.4.15)(esbuild@0.25.0)) - ws: 8.16.0 + ws: 8.18.3 optionalDependencies: webpack: 5.90.3(@swc/core@1.4.15)(esbuild@0.25.0) transitivePeerDependencies: @@ -23459,8 +23452,6 @@ snapshots: ws@7.5.9: {} - ws@8.16.0: {} - ws@8.18.3: {} xdg-basedir@5.1.0: {} From b997419818ec398a8afbfc31b861c14ef4ac3228 Mon Sep 17 00:00:00 2001 From: yujeong-jeon Date: Tue, 18 Nov 2025 16:13:13 +0900 Subject: [PATCH 2/6] [#202] Enhance sanitization options with flexible DOM implementation support (jsdom, happy-dom, linkeddom) --- packages/safe-html-react-parser/README.md | 116 +++++++++++++- packages/safe-html-react-parser/package.json | 20 ++- packages/safe-html-react-parser/src/index.ts | 26 ++- .../src/utils/dompurify.ts | 151 +++++++++++++++--- pnpm-lock.yaml | 50 +++++- 5 files changed, 323 insertions(+), 40 deletions(-) diff --git a/packages/safe-html-react-parser/README.md b/packages/safe-html-react-parser/README.md index b3f08a55..32edbe3b 100644 --- a/packages/safe-html-react-parser/README.md +++ b/packages/safe-html-react-parser/README.md @@ -1,6 +1,6 @@ # safe-html-react-parser -A secure wrapper for **html-react-parser** with **isomorphic-dompurify** that automatically sanitizes HTML before parsing. +A secure wrapper for **html-react-parser** with **DOMPurify** that automatically sanitizes HTML before parsing. ## What it does @@ -8,10 +8,8 @@ A secure wrapper for **html-react-parser** with **isomorphic-dompurify** that au - ⚛️ **React**: Seamlessly integrates with html-react-parser - 🌐 **Universal**: Works in both browser and Node.js (SSR) environments - 🏷️ **Custom Tags**: Handles project-specific tags like `` safely - -## Requirements - -- Node.js >=20.19.5: isomorphic-dompurify@^2.30.1 +- 🚀 **Flexible**: Choose your DOM implementation (jsdom, happy-dom, or linkedom) +- ⚡ **Optimized**: Built-in caching and memory management ## Installation @@ -19,6 +17,23 @@ A secure wrapper for **html-react-parser** with **isomorphic-dompurify** that au npm install @naverpay/safe-html-react-parser ``` +### Choosing a DOM Implementation (Server-Side Only) + +For server-side rendering, you need to install one of the following DOM implementations: + +```bash +# Option 1: jsdom (most complete, heavier) +npm install jsdom + +# Option 2: happy-dom (faster, lighter, recommended) +npm install happy-dom + +# Option 3: linkedom (fastest, lightest) +npm install linkedom +``` + +The library will automatically detect and use the first available implementation in this order: jsdom → happy-dom → linkedom. + ## Basic Usage ```tsx @@ -119,6 +134,63 @@ const result = safeParse(html, { }) ``` +### Configuring DOM Implementation (Server-Side) + +You have two ways to configure the DOM implementation: + +#### Method 1: Per-call configuration (Recommended) + +Pass `domPurifyOptions` directly to `safeParse()`: + +```tsx +import { safeParse } from '@naverpay/safe-html-react-parser' +import { Window } from 'happy-dom' + +const result = safeParse(htmlString, { + domPurifyOptions: { + domWindowFactory: () => new Window(), + enableCache: true, + maxCacheSize: 100 + } +}) +``` + +#### Method 2: Global configuration + +Configure once at app initialization: + +```tsx +import { configureDOMPurify } from '@naverpay/safe-html-react-parser' + +// Using jsdom +import { JSDOM } from 'jsdom' +configureDOMPurify({ + domWindowFactory: () => new JSDOM(''), + enableCache: true, + maxCacheSize: 100, + recreateInterval: 1000 // Recreate DOM instance every 1000 sanitizations +}) + +// Using happy-dom (recommended for better performance) +import { Window } from 'happy-dom' +configureDOMPurify({ + domWindowFactory: () => new Window(), + enableCache: true, + recreateInterval: 500 +}) + +// Using linkedom (fastest, minimal footprint) +import { parseHTML } from 'linkedom' +configureDOMPurify({ + domWindowFactory: () => parseHTML(''), + enableCache: true +}) +``` + +> [!NOTE] +> +> If you don't configure anything, the library will automatically try jsdom → happy-dom → linkedom in that order. + ## Default Allowed Tags By default, the following HTML tags are allowed: @@ -132,6 +204,37 @@ ALLOWED_TAGS: [ ] ``` +## Performance Optimization + +### Caching + +By default, caching is enabled to improve performance: + +```tsx +configureDOMPurify({ + enableCache: true, // Default: true + maxCacheSize: 100, // Default: 100 +}) +``` + +### Memory Management + +The DOM instance is automatically recreated periodically to prevent memory leaks: + +```tsx +configureDOMPurify({ + recreateInterval: 1000 // Default: 1000 sanitization calls +}) +``` + +### DOM Implementation Comparison + +| Implementation | Speed | Memory | Completeness | Recommended For | +|----------------|-------|--------|--------------|-----------------| +| **jsdom** | Slower | Higher | Most complete | Maximum compatibility | +| **happy-dom** | Fast | Medium | Good | **Balanced (Recommended)** | +| **linkedom** | Fastest | Lowest | Basic | Performance-critical apps | + ## Security Notes - All HTML is sanitized by DOMPurify before parsing @@ -142,7 +245,8 @@ ALLOWED_TAGS: [ ## Built with - [html-react-parser@^5.2.7](https://github.com/remarkablemark/html-react-parser) - HTML string to React element parser -- [isomorphic-dompurify@^2.30.1](https://github.com/kkomelin/isomorphic-dompurify) - Universal XSS sanitizer +- [dompurify@^3.3.0](https://github.com/cure53/DOMPurify) - XSS sanitizer +- Optional: [jsdom](https://github.com/jsdom/jsdom), [happy-dom](https://github.com/capricorn86/happy-dom), or [linkedom](https://github.com/WebReflection/linkedom) ## License diff --git a/packages/safe-html-react-parser/package.json b/packages/safe-html-react-parser/package.json index 4339f05a..d891f21b 100644 --- a/packages/safe-html-react-parser/package.json +++ b/packages/safe-html-react-parser/package.json @@ -20,18 +20,34 @@ "author": "@NaverPayDev/frontend", "dependencies": { "dompurify": "^3.3.0", - "html-react-parser": "^5.2.7", - "jsdom": "^27.2.0" + "html-react-parser": "^5.2.7" }, "devDependencies": { "@types/jsdom": "^27.0.0", "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "happy-dom": "^17.4.4", + "jsdom": "^27.2.0", + "linkedom": "^0.18.12", "react": "0.14 || 15 || 16 || 17 || 18 || 19" }, "peerDependencies": { "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "happy-dom": "^17.4.4", + "jsdom": "^27.2.0", + "linkedom": "^0.18.12", "react": "0.14 || 15 || 16 || 17 || 18 || 19" }, + "peerDependenciesMeta": { + "jsdom": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "linkedom": { + "optional": true + } + }, "scripts": { "clean": "rm -rf dist", "build": "npm run clean && vite build", diff --git a/packages/safe-html-react-parser/src/index.ts b/packages/safe-html-react-parser/src/index.ts index ae6cb711..2a1f0018 100644 --- a/packages/safe-html-react-parser/src/index.ts +++ b/packages/safe-html-react-parser/src/index.ts @@ -4,10 +4,14 @@ */ import * as htmlReactParser from 'html-react-parser' -import {sanitizeHtml, type SanitizeConfig} from './utils/dompurify' +import {sanitizeHtml, type SanitizerOptions as DOMPurifyOptionsType, type SanitizeConfig} from './utils/dompurify' import type {DOMNode, HTMLReactParserOptions} from 'html-react-parser' +// Re-export configuration function +export {configureDOMPurify} from './utils/dompurify' +export type {DOMWindow, DOMWindowFactory, SanitizerOptions as DOMPurifyOptions} from './utils/dompurify' + // Solving the issue of html-react-parser re-exporting cjs modules in esm // In CJS: htmlReactParser.default.default is the actual function // In ESM: htmlReactParser.default is the function @@ -17,13 +21,27 @@ const parse = ((htmlReactParser as any).default?.default || export interface SafeParseOptions extends HTMLReactParserOptions { /** - * DOMPurify Options + * DOMPurify sanitization configuration */ sanitizeConfig?: SanitizeConfig /** * Custom tag preservation option (temporary conversion before and after DOMPurify processing) */ preserveCustomTags?: string[] + /** + * Server-side DOMPurify options (DOM implementation, caching, etc.) + * Only used on server-side. Ignored on client-side. + * + * @example + * import { Window } from 'happy-dom' + * safeParse(html, { + * domPurifyOptions: { + * domWindowFactory: () => new Window(), + * enableCache: true + * } + * }) + */ + domPurifyOptions?: DOMPurifyOptionsType } export const DEFAULT_SANITIZE_CONFIG: SanitizeConfig = { @@ -62,7 +80,7 @@ export const DEFAULT_SANITIZE_CONFIG: SanitizeConfig = { * @returns Parsed React elements */ export function safeParse(htmlString: string, options: SafeParseOptions = {}) { - const {sanitizeConfig = DEFAULT_SANITIZE_CONFIG, preserveCustomTags, ...parserOptions} = options + const {sanitizeConfig = DEFAULT_SANITIZE_CONFIG, preserveCustomTags, domPurifyOptions, ...parserOptions} = options // Temporarily convert custom tags to safe tags to preserve them during DOMPurify processing const processedHtml = @@ -74,7 +92,7 @@ export function safeParse(htmlString: string, options: SafeParseOptions = {}) { htmlString, ) || htmlString - const sanitizedHtml = sanitizeHtml(processedHtml, sanitizeConfig) + const sanitizedHtml = sanitizeHtml(processedHtml, sanitizeConfig, domPurifyOptions) if (!sanitizedHtml) { return null diff --git a/packages/safe-html-react-parser/src/utils/dompurify.ts b/packages/safe-html-react-parser/src/utils/dompurify.ts index e8039607..4cb36653 100644 --- a/packages/safe-html-react-parser/src/utils/dompurify.ts +++ b/packages/safe-html-react-parser/src/utils/dompurify.ts @@ -1,7 +1,19 @@ import createDOMPurify from 'dompurify' -import {JSDOM} from 'jsdom' -export interface Options { +/** + * DOM Window interface that can be provided by jsdom, happy-dom, linkedom, etc. + */ +export interface DOMWindow { + document: Document + [key: string]: unknown +} + +/** + * Factory function to create a DOM window instance + */ +export type DOMWindowFactory = () => DOMWindow | {window: DOMWindow} + +export interface SanitizerOptions { /** * Interval for recreating the DOMPurify instance to prevent memory leaks * Default is 1000 sanitization calls @@ -17,6 +29,26 @@ export interface Options { * Default is 100 entries */ maxCacheSize?: number + /** + * Custom DOM window factory for server-side rendering + * Supports jsdom, happy-dom, linkedom, or any compatible DOM implementation + * + * @example + * // Using jsdom + * import { JSDOM } from 'jsdom' + * configureDOMPurify({ domWindowFactory: () => new JSDOM('') }) + * + * @example + * // Using happy-dom + * import { Window } from 'happy-dom' + * configureDOMPurify({ domWindowFactory: () => new Window() }) + * + * @example + * // Using linkedom + * import { parseHTML } from 'linkedom' + * configureDOMPurify({ domWindowFactory: () => parseHTML('') }) + */ + domWindowFactory?: DOMWindowFactory } export type DomPurify = ReturnType @@ -27,23 +59,39 @@ export type SanitizeConfig = SanitizeParams[1] class OptimizedDOMPurify { recreateInterval: number - jsdom: JSDOM | null + domInstance: {window: DOMWindow} | null + domWindowFactory: DOMWindowFactory purify: ReturnType | null callCount: number enableCache: boolean cache: Map | null maxCacheSize: number - constructor( - options: Options = { - enableCache: true, - }, - ) { + + constructor(options: SanitizerOptions = {}) { this.recreateInterval = options?.recreateInterval || 1000 - this.enableCache = !!options?.enableCache + this.enableCache = options?.enableCache !== false // Default true this.cache = this.enableCache ? new Map() : null this.maxCacheSize = options?.maxCacheSize || 100 - this.jsdom = null + if (!options?.domWindowFactory) { + throw new Error( + 'No DOM implementation configured for server-side rendering.\n' + + 'Please configure DOMPurify with one of the following:\n\n' + + ' import { configureDOMPurify } from "@naverpay/safe-html-react-parser"\n' + + ' import { JSDOM } from "jsdom"\n' + + ' configureDOMPurify({ domWindowFactory: () => new JSDOM("") })\n\n' + + 'Or use happy-dom for better performance:\n' + + ' import { Window } from "happy-dom"\n' + + ' configureDOMPurify({ domWindowFactory: () => new Window() })\n\n' + + 'Or use linkedom for minimal footprint:\n' + + ' import { parseHTML } from "linkedom"\n' + + ' configureDOMPurify({ domWindowFactory: () => parseHTML("") })', + ) + } + + this.domWindowFactory = options.domWindowFactory + + this.domInstance = null this.purify = null this.callCount = 0 @@ -52,9 +100,9 @@ class OptimizedDOMPurify { initialize() { // Cleanup previous instance - if (this.jsdom?.window) { + if (this.domInstance?.window) { try { - const doc = this.jsdom.window.document + const doc = this.domInstance.window.document if (doc.body) { doc.body.innerHTML = '' } @@ -67,18 +115,24 @@ class OptimizedDOMPurify { } catch { // ignore cleanup errors } - this.jsdom.window.close() + + const win = this.domInstance.window as unknown as {close?: () => void} + if (typeof win.close === 'function') { + win.close() + } } this.purify = null - this.jsdom = null + this.domInstance = null if (global.gc && typeof global.gc === 'function') { global.gc() } - this.jsdom = new JSDOM('') - this.purify = createDOMPurify(this.jsdom.window) + const result = this.domWindowFactory() + this.domInstance = 'window' in result ? (result as {window: DOMWindow}) : {window: result} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.purify = createDOMPurify(this.domInstance.window as any) this.callCount = 0 if (this.cache) { @@ -112,28 +166,81 @@ class OptimizedDOMPurify { } cleanup() { - if (this.jsdom?.window) { - this.jsdom.window.close() + if (this.domInstance?.window) { + const win = this.domInstance.window as unknown as {close?: () => void} + if (typeof win.close === 'function') { + win.close() + } } if (this.cache) { this.cache.clear() } - this.jsdom = null + this.domInstance = null this.purify = null } } let instance: OptimizedDOMPurify | null = null -function getSanitizer(options?: Options) { +function getSanitizer(options?: SanitizerOptions) { if (!instance) { instance = new OptimizedDOMPurify(options) } return instance } -export function sanitizeHtml(dirty: DirtyHtml, config?: SanitizeConfig) { +/** + * Configure DOMPurify settings globally (optional) + * Alternatively, you can pass options directly to sanitizeHtml + * + * @example + * // Using jsdom + * import { JSDOM } from 'jsdom' + * configureDOMPurify({ + * domWindowFactory: () => new JSDOM(''), + * enableCache: true, + * maxCacheSize: 100 + * }) + * + * @example + * // Using happy-dom for better performance + * import { Window } from 'happy-dom' + * configureDOMPurify({ + * domWindowFactory: () => new Window(), + * recreateInterval: 500 + * }) + */ +export function configureDOMPurify(options: SanitizerOptions) { + // Reset instance to apply new configuration + if (instance) { + instance.cleanup() + instance = null + } + // Create new instance with provided options + instance = new OptimizedDOMPurify(options) +} + +/** + * Sanitize HTML string using DOMPurify + * + * @param dirty - HTML string to sanitize + * @param config - DOMPurify configuration + * @param options - Server-side options (DOM implementation, caching, etc.) + * + * @example + * // Client-side (automatic) + * sanitizeHtml('

Hello

') + * + * @example + * // Server-side with custom DOM + * import { Window } from 'happy-dom' + * sanitizeHtml('

Hello

', undefined, { + * domWindowFactory: () => new Window(), + * enableCache: true + * }) + */ +export function sanitizeHtml(dirty: DirtyHtml, config?: SanitizeConfig, options?: SanitizerOptions) { const isClientSide = typeof window !== 'undefined' - const sanitizer = isClientSide ? createDOMPurify : getSanitizer() + const sanitizer = isClientSide ? createDOMPurify : getSanitizer(options) return sanitizer.sanitize(dirty, config) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ef14e2f..0e6c68e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,9 +255,6 @@ importers: html-react-parser: specifier: ^5.2.7 version: 5.2.7(@types/react@18.3.20)(react@18.3.1) - jsdom: - specifier: ^27.2.0 - version: 27.2.0(postcss@8.5.3) devDependencies: '@types/jsdom': specifier: ^27.0.0 @@ -265,6 +262,15 @@ importers: '@types/react': specifier: 0.14 || 15 || 16 || 17 || 18 || 19 version: 18.3.20 + happy-dom: + specifier: ^17.4.4 + version: 17.4.4 + jsdom: + specifier: ^27.2.0 + version: 27.2.0(postcss@8.5.3) + linkedom: + specifier: ^0.18.12 + version: 0.18.12 react: specifier: 0.14 || 15 || 16 || 17 || 18 || 19 version: 18.3.1 @@ -4646,6 +4652,9 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + cssstyle@5.3.3: resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} engines: {node: '>=20'} @@ -5916,6 +5925,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-minifier-terser@6.1.0: resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} engines: {node: '>=12'} @@ -6743,6 +6755,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + linkify-it@4.0.1: resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==} @@ -9662,6 +9683,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -16238,7 +16262,7 @@ snapshots: css-what: 6.1.0 domelementtype: 2.3.0 domhandler: 5.0.3 - domutils: 3.1.0 + domutils: 3.2.2 cheerio@1.0.0-rc.12: dependencies: @@ -16628,7 +16652,7 @@ snapshots: boolbase: 1.0.0 css-what: 6.1.0 domhandler: 5.0.3 - domutils: 3.1.0 + domutils: 3.2.2 nth-check: 2.1.1 css-tree@1.1.3: @@ -16717,6 +16741,8 @@ snapshots: dependencies: css-tree: 2.2.1 + cssom@0.5.0: {} + cssstyle@5.3.3(postcss@8.5.3): dependencies: '@asamuzakjp/css-color': 4.0.5 @@ -18341,6 +18367,8 @@ snapshots: html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + html-minifier-terser@6.1.0: dependencies: camel-case: 4.1.2 @@ -18413,7 +18441,7 @@ snapshots: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 - domutils: 3.1.0 + domutils: 3.2.2 entities: 4.5.0 http-cache-semantics@4.1.1: {} @@ -19359,6 +19387,14 @@ snapshots: lines-and-columns@1.2.4: {} + linkedom@0.18.12: + dependencies: + css-select: 5.1.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.0.0 + uhyphen: 0.2.0 + linkify-it@4.0.1: dependencies: uc.micro: 1.0.6 @@ -22715,6 +22751,8 @@ snapshots: uglify-js@3.17.4: optional: true + uhyphen@0.2.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.3 From 3eea5d1c70f09985f5ba5a8a5d0b53e60159e78d Mon Sep 17 00:00:00 2001 From: yujeong-jeon <148525642+yujeong-jeon@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:10:49 +0900 Subject: [PATCH 3/6] Update safe-html-react-parser with custom DOM implementation Replace isomorphic-dompurify with a custom implementation that supports flexible DOM libraries. --- .changeset/fifty-cars-heal.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fifty-cars-heal.md diff --git a/.changeset/fifty-cars-heal.md b/.changeset/fifty-cars-heal.md new file mode 100644 index 00000000..0f439f02 --- /dev/null +++ b/.changeset/fifty-cars-heal.md @@ -0,0 +1,7 @@ +--- +"@naverpay/safe-html-react-parser": minor +--- + +[safe-html-react-parser] Replace isomorphic-dompurify with custom implementation supporting flexible DOM libraries + +PR: [[safe-html-react-parser] Replace isomorphic-dompurify with custom implementation supporting flexible DOM libraries](https://github.com/NaverPayDev/pie/pull/203) From 26c9e5172c6ee35fb1bbe3abbdad1fc624a9bf24 Mon Sep 17 00:00:00 2001 From: yujeong-jeon Date: Wed, 19 Nov 2025 12:10:20 +0900 Subject: [PATCH 4/6] [#202] Refactor DOM types and factory function for improved flexibility and clarity --- .../src/utils/dompurify.ts | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/safe-html-react-parser/src/utils/dompurify.ts b/packages/safe-html-react-parser/src/utils/dompurify.ts index 4cb36653..7179dce8 100644 --- a/packages/safe-html-react-parser/src/utils/dompurify.ts +++ b/packages/safe-html-react-parser/src/utils/dompurify.ts @@ -1,17 +1,49 @@ import createDOMPurify from 'dompurify' +import type {Window as HappyDOMWindow} from 'happy-dom' +import type {JSDOM, DOMWindow as JSDOMWindow} from 'jsdom' +import type {parseHTML} from 'linkedom' + /** - * DOM Window interface that can be provided by jsdom, happy-dom, linkedom, etc. + * DOM Window types from supported libraries + * - jsdom: JSDOM Window + * - happy-dom: Window + * - linkedom: parseHTML result + * + * @example + * import { JSDOM } from 'jsdom' + * const jsdomWindow: DOMWindow = new JSDOM('') + * + * @example + * import { Window } from 'happy-dom' + * const happyDomWindow: DOMWindow = new Window() + * + * @example + * import { parseHTML } from 'linkedom' + * const linkedomWindow: DOMWindow = parseHTML('') */ -export interface DOMWindow { - document: Document - [key: string]: unknown -} +export type DOMWindow = JSDOMWindow | HappyDOMWindow | ReturnType + +/** + * DOM instance types that can be provided directly or via factory + */ +export type DOMInstance = JSDOM | HappyDOMWindow | ReturnType /** - * Factory function to create a DOM window instance + * Factory function to create a DOM window instance, or the instance itself + * - jsdom: JSDOM instance or factory returning JSDOM + * - happy-dom: Window instance or factory returning Window + * - linkedom: parseHTML result or factory returning parseHTML result + * + * @example + * // Direct instance + * domWindowFactory: new Window() + * + * @example + * // Factory function + * domWindowFactory: () => new Window() */ -export type DOMWindowFactory = () => DOMWindow | {window: DOMWindow} +export type DOMWindowFactory = (() => DOMInstance) | DOMInstance export interface SanitizerOptions { /** @@ -109,8 +141,8 @@ class OptimizedDOMPurify { if (doc.head) { doc.head.innerHTML = '' } - while (doc.firstChild) { - doc.removeChild(doc.firstChild) + if (doc.documentElement) { + doc.documentElement.innerHTML = '' } } catch { // ignore cleanup errors @@ -129,7 +161,7 @@ class OptimizedDOMPurify { global.gc() } - const result = this.domWindowFactory() + const result = typeof this.domWindowFactory === 'function' ? this.domWindowFactory() : this.domWindowFactory this.domInstance = 'window' in result ? (result as {window: DOMWindow}) : {window: result} // eslint-disable-next-line @typescript-eslint/no-explicit-any this.purify = createDOMPurify(this.domInstance.window as any) From 76d76a3e6297c21708b640ae0f4fa9161cf61b82 Mon Sep 17 00:00:00 2001 From: yujeong-jeon Date: Thu, 20 Nov 2025 01:00:37 +0900 Subject: [PATCH 5/6] [#202] Fix cache key to ensure consistent serialization of Node types --- .../safe-html-react-parser/src/utils/dompurify.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/safe-html-react-parser/src/utils/dompurify.ts b/packages/safe-html-react-parser/src/utils/dompurify.ts index 7179dce8..b15625db 100644 --- a/packages/safe-html-react-parser/src/utils/dompurify.ts +++ b/packages/safe-html-react-parser/src/utils/dompurify.ts @@ -96,7 +96,7 @@ class OptimizedDOMPurify { purify: ReturnType | null callCount: number enableCache: boolean - cache: Map | null + cache: Map | null maxCacheSize: number constructor(options: SanitizerOptions = {}) { @@ -174,19 +174,22 @@ class OptimizedDOMPurify { sanitize(dirty: DirtyHtml, config?: SanitizeConfig) { if (this.enableCache && !config && this.cache) { - if (this.cache.has(dirty)) { - return this.cache.get(dirty) + // Serialize Node to string for consistent cache key + const cacheKey = typeof dirty === 'string' ? dirty : dirty.toString() + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey) } } const cleanHtml = this.purify?.sanitize(dirty, config) if (this.enableCache && !config && this.cache) { + const cacheKey = typeof dirty === 'string' ? dirty : dirty.toString() if (this.cache.size >= this.maxCacheSize) { const firstKey = this.cache.keys().next().value firstKey && this.cache.delete(firstKey) } - cleanHtml && this.cache.set(dirty, cleanHtml) + cleanHtml && this.cache.set(cacheKey, cleanHtml) } this.callCount++ From 3a166d5216a2e421ef346baf784450f9c51b8de1 Mon Sep 17 00:00:00 2001 From: yujeong-jeon Date: Thu, 20 Nov 2025 01:01:30 +0900 Subject: [PATCH 6/6] [#202] Refactor lru caching in DOMPurify --- .../src/utils/dompurify.ts | 19 ++++---- .../src/utils/lru-cache.ts | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 packages/safe-html-react-parser/src/utils/lru-cache.ts diff --git a/packages/safe-html-react-parser/src/utils/dompurify.ts b/packages/safe-html-react-parser/src/utils/dompurify.ts index b15625db..ef2d99b4 100644 --- a/packages/safe-html-react-parser/src/utils/dompurify.ts +++ b/packages/safe-html-react-parser/src/utils/dompurify.ts @@ -1,5 +1,7 @@ import createDOMPurify from 'dompurify' +import {LRUCache} from './lru-cache' + import type {Window as HappyDOMWindow} from 'happy-dom' import type {JSDOM, DOMWindow as JSDOMWindow} from 'jsdom' import type {parseHTML} from 'linkedom' @@ -96,14 +98,14 @@ class OptimizedDOMPurify { purify: ReturnType | null callCount: number enableCache: boolean - cache: Map | null + cache: LRUCache | null maxCacheSize: number constructor(options: SanitizerOptions = {}) { this.recreateInterval = options?.recreateInterval || 1000 this.enableCache = options?.enableCache !== false // Default true - this.cache = this.enableCache ? new Map() : null this.maxCacheSize = options?.maxCacheSize || 100 + this.cache = this.enableCache ? new LRUCache(this.maxCacheSize) : null if (!options?.domWindowFactory) { throw new Error( @@ -176,20 +178,17 @@ class OptimizedDOMPurify { if (this.enableCache && !config && this.cache) { // Serialize Node to string for consistent cache key const cacheKey = typeof dirty === 'string' ? dirty : dirty.toString() - if (this.cache.has(cacheKey)) { - return this.cache.get(cacheKey) + const cached = this.cache.get(cacheKey) + if (cached) { + return cached } } const cleanHtml = this.purify?.sanitize(dirty, config) - if (this.enableCache && !config && this.cache) { + if (this.enableCache && !config && this.cache && cleanHtml) { const cacheKey = typeof dirty === 'string' ? dirty : dirty.toString() - if (this.cache.size >= this.maxCacheSize) { - const firstKey = this.cache.keys().next().value - firstKey && this.cache.delete(firstKey) - } - cleanHtml && this.cache.set(cacheKey, cleanHtml) + this.cache.set(cacheKey, cleanHtml) } this.callCount++ diff --git a/packages/safe-html-react-parser/src/utils/lru-cache.ts b/packages/safe-html-react-parser/src/utils/lru-cache.ts new file mode 100644 index 00000000..6e720ea1 --- /dev/null +++ b/packages/safe-html-react-parser/src/utils/lru-cache.ts @@ -0,0 +1,47 @@ +export class LRUCache { + private cache: Map + private maxSize: number + + constructor(maxSize: number) { + this.cache = new Map() + this.maxSize = maxSize + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) { + return undefined + } + // Move to end (most recently used) + const value = this.cache.get(key)! + this.cache.delete(key) + this.cache.set(key, value) + return value + } + + set(key: K, value: V): void { + // Remove if exists to reinsert at end + if (this.cache.has(key)) { + this.cache.delete(key) + } + // Evict oldest if at capacity + else if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value + if (firstKey !== undefined) { + this.cache.delete(firstKey) + } + } + this.cache.set(key, value) + } + + has(key: K): boolean { + return this.cache.has(key) + } + + clear(): void { + this.cache.clear() + } + + get size(): number { + return this.cache.size + } +}