Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/brave-otters-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@naverpay/safe-html-react-parser": major
---

[safe-html-react-parser] isomorphic-dompurify로 마이그레이션

업스트림 메모리 누수 이슈([kkomelin/isomorphic-dompurify#368](https://github.com/kkomelin/isomorphic-dompurify/issues/368))를 우회하기 위해 도입했던 커스텀 DOMPurify 래퍼(jsdom/happy-dom/linkedom 중 선택 지원, LRU 캐시, recreate interval 등)를 제거하고, 누수가 해결된 `isomorphic-dompurify`를 직접 사용하도록 단순화합니다.

**Breaking Changes**

- `configureDOMPurify` 함수 export 제거
- `SafeParseOptions.domPurifyOptions` 옵션 제거
- `DOMWindow`, `DOMWindowFactory`, `DOMPurifyOptions` 타입 export 제거
- `jsdom` / `happy-dom` / `linkedom` 피어 의존성 제거 (별도 설치 불필요)

Issue: [#202](https://github.com/NaverPayDev/pie/issues/202)
22 changes: 2 additions & 20 deletions packages/safe-html-react-parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,17 @@
],
"author": "@NaverPayDev/frontend",
"dependencies": {
"dompurify": "^3.3.0",
"html-react-parser": "^5.2.7"
"html-react-parser": "^5.2.7",
"isomorphic-dompurify": "^3.12.0"
},
"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",
Expand Down
24 changes: 3 additions & 21 deletions packages/safe-html-react-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,10 @@
*/
import * as htmlReactParser from 'html-react-parser'

import {sanitizeHtml, type SanitizerOptions as DOMPurifyOptionsType, type SanitizeConfig} from './utils/dompurify'
import {sanitizeHtml, 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
Expand All @@ -28,20 +24,6 @@ export interface SafeParseOptions extends HTMLReactParserOptions {
* 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 = {
Expand Down Expand Up @@ -80,7 +62,7 @@ export const DEFAULT_SANITIZE_CONFIG: SanitizeConfig = {
* @returns Parsed React elements
*/
export function safeParse(htmlString: string, options: SafeParseOptions = {}) {
const {sanitizeConfig = DEFAULT_SANITIZE_CONFIG, preserveCustomTags, domPurifyOptions, ...parserOptions} = options
const {sanitizeConfig = DEFAULT_SANITIZE_CONFIG, preserveCustomTags, ...parserOptions} = options

// Temporarily convert custom tags to safe tags to preserve them during DOMPurify processing
const processedHtml =
Expand All @@ -92,7 +74,7 @@ export function safeParse(htmlString: string, options: SafeParseOptions = {}) {
htmlString,
) || htmlString

const sanitizedHtml = sanitizeHtml(processedHtml, sanitizeConfig, domPurifyOptions)
const sanitizedHtml = sanitizeHtml(processedHtml, sanitizeConfig)

if (!sanitizedHtml) {
return null
Expand Down
277 changes: 4 additions & 273 deletions packages/safe-html-react-parser/src/utils/dompurify.ts
Original file line number Diff line number Diff line change
@@ -1,280 +1,11 @@
import createDOMPurify from 'dompurify'
import DOMPurify from 'isomorphic-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'

/**
* 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('<!DOCTYPE html>')
*
* @example
* import { Window } from 'happy-dom'
* const happyDomWindow: DOMWindow = new Window()
*
* @example
* import { parseHTML } from 'linkedom'
* const linkedomWindow: DOMWindow = parseHTML('<!DOCTYPE html>')
*/
export type DOMWindow = JSDOMWindow | HappyDOMWindow | ReturnType<typeof parseHTML>

/**
* DOM instance types that can be provided directly or via factory
*/
export type DOMInstance = JSDOM | HappyDOMWindow | ReturnType<typeof parseHTML>

/**
* 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 = (() => DOMInstance) | DOMInstance

export interface SanitizerOptions {
/**
* 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
/**
* 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('<!DOCTYPE html>') })
*
* @example
* // Using happy-dom
* import { Window } from 'happy-dom'
* configureDOMPurify({ domWindowFactory: () => new Window() })
*
* @example
* // Using linkedom
* import { parseHTML } from 'linkedom'
* configureDOMPurify({ domWindowFactory: () => parseHTML('<!DOCTYPE html>') })
*/
domWindowFactory?: DOMWindowFactory
}

export type DomPurify = ReturnType<typeof createDOMPurify>
export type DomPurify = typeof DOMPurify

type SanitizeParams = Parameters<DomPurify['sanitize']>
export type DirtyHtml = SanitizeParams[0]
export type SanitizeConfig = SanitizeParams[1]

class OptimizedDOMPurify {
recreateInterval: number
domInstance: {window: DOMWindow} | null
domWindowFactory: DOMWindowFactory
purify: ReturnType<typeof createDOMPurify> | null
callCount: number
enableCache: boolean
cache: LRUCache<string, string> | null
maxCacheSize: number

constructor(options: SanitizerOptions = {}) {
this.recreateInterval = options?.recreateInterval || 1000
this.enableCache = options?.enableCache !== false // Default true
this.maxCacheSize = options?.maxCacheSize || 100
this.cache = this.enableCache ? new LRUCache(this.maxCacheSize) : 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("<!DOCTYPE html>") })\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("<!DOCTYPE html>") })',
)
}

this.domWindowFactory = options.domWindowFactory

this.domInstance = null
this.purify = null
this.callCount = 0

this.initialize()
}

initialize() {
// Cleanup previous instance
if (this.domInstance?.window) {
try {
const doc = this.domInstance.window.document
if (doc.body) {
doc.body.innerHTML = ''
}
if (doc.head) {
doc.head.innerHTML = ''
}
if (doc.documentElement) {
doc.documentElement.innerHTML = ''
}
} catch {
// ignore cleanup errors
}

const win = this.domInstance.window as unknown as {close?: () => void}
if (typeof win.close === 'function') {
win.close()
}
}

this.purify = null
this.domInstance = null

if (global.gc && typeof global.gc === 'function') {
global.gc()
}

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)
this.callCount = 0

if (this.cache) {
this.cache.clear()
}
}

sanitize(dirty: DirtyHtml, config?: SanitizeConfig) {
if (this.enableCache && !config && this.cache) {
// Serialize Node to string for consistent cache key
const cacheKey = typeof dirty === 'string' ? dirty : dirty.toString()
const cached = this.cache.get(cacheKey)
if (cached) {
return cached
}
}

const cleanHtml = this.purify?.sanitize(dirty, config)

if (this.enableCache && !config && this.cache && cleanHtml) {
const cacheKey = typeof dirty === 'string' ? dirty : dirty.toString()
this.cache.set(cacheKey, cleanHtml)
}

this.callCount++
if (this.callCount >= this.recreateInterval) {
this.initialize()
}

return cleanHtml
}

cleanup() {
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.domInstance = null
this.purify = null
}
}

let instance: OptimizedDOMPurify | null = null

function getSanitizer(options?: SanitizerOptions) {
if (!instance) {
instance = new OptimizedDOMPurify(options)
}
return instance
}

/**
* Configure DOMPurify settings globally (optional)
* Alternatively, you can pass options directly to sanitizeHtml
*
* @example
* // Using jsdom
* import { JSDOM } from 'jsdom'
* configureDOMPurify({
* domWindowFactory: () => new JSDOM('<!DOCTYPE html>'),
* 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('<p>Hello <script>alert("XSS")</script></p>')
*
* @example
* // Server-side with custom DOM
* import { Window } from 'happy-dom'
* sanitizeHtml('<p>Hello</p>', 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(options)
return sanitizer.sanitize(dirty, config)
export function sanitizeHtml(dirty: DirtyHtml, config?: SanitizeConfig) {
return DOMPurify.sanitize(dirty as string, config)
}
Loading
Loading