Skip to content

Commit 112fd3c

Browse files
authored
Merge pull request #389 from kkomelin/memory-leak
feat: add clearWindow() to fix memory leak in long-running processes
2 parents 34c3802 + f34f7b3 commit 112fd3c

5 files changed

Lines changed: 170 additions & 13 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ import { sanitize } from "isomorphic-dompurify";
5959
const clean = sanitize(dirtyString);
6060
```
6161

62+
## Memory Management (Server)
63+
64+
In long-running Node.js processes, the internal jsdom window accumulates DOM state across sanitization calls, which can cause progressive slowdown and memory growth. Use `clearWindow()` to periodically release these resources:
65+
66+
```javascript
67+
import { sanitize, clearWindow } from "isomorphic-dompurify";
68+
69+
// Sanitize as usual
70+
const clean = sanitize(dirtyString);
71+
72+
// Release jsdom resources when appropriate (e.g. after a request, after a batch)
73+
clearWindow();
74+
```
75+
76+
`clearWindow()` closes the current jsdom window and creates a fresh one. All import styles (default and named) continue to work after calling it.
77+
78+
> **Note:** Any hooks or config set via `addHook`/`setConfig` will need to be re-applied after calling `clearWindow()`. In the browser build, `clearWindow()` is a no-op.
79+
6280
## Web Worker Support
6381

6482
The `isomorphic-dompurify` library is [compatible with Web Workers](https://github.com/kkomelin/isomorphic-dompurify/pull/242),

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "isomorphic-dompurify",
3-
"version": "3.0.0-rc.1",
3+
"version": "3.0.0-rc.2",
44
"description": "Makes it possible to use DOMPurify on server and client in the same way.",
55
"keywords": [
66
"security",
@@ -18,7 +18,7 @@
1818
],
1919
"scripts": {
2020
"build": "tsup",
21-
"test": "vitest run"
21+
"test": "NODE_OPTIONS='--expose-gc' vitest run"
2222
},
2323
"bugs": {
2424
"url": "https://github.com/kkomelin/isomorphic-dompurify/issues"

src/browser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ export const clearConfig = DOMPurify.clearConfig.bind(DOMPurify);
1212
export const isValidAttribute = DOMPurify.isValidAttribute.bind(DOMPurify);
1313
export const version = DOMPurify.version;
1414
export const removed = DOMPurify.removed;
15+
16+
export function clearWindow(): void {
17+
// No-op in browser — no jsdom window to manage
18+
}

src/index.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,36 @@ import DOMPurifyFactory from 'dompurify';
22
import type { DOMPurify as DOMPurifyI } from 'dompurify';
33
import { JSDOM } from 'jsdom';
44

5-
const { window } = new JSDOM('<!DOCTYPE html>');
6-
const DOMPurify: DOMPurifyI = DOMPurifyFactory(window as unknown as Parameters<typeof DOMPurifyFactory>[0]);
5+
let window = new JSDOM('<!DOCTYPE html>').window;
6+
let purify: DOMPurifyI = DOMPurifyFactory(window as unknown as Parameters<typeof DOMPurifyFactory>[0]);
7+
8+
// Proxy so `DOMPurify.method()` always delegates to the current instance
9+
const DOMPurify: DOMPurifyI = new Proxy({} as DOMPurifyI, {
10+
get(_, prop) {
11+
const value = (purify as any)[prop];
12+
return typeof value === 'function' ? value.bind(purify) : value;
13+
},
14+
});
715

816
export default DOMPurify;
9-
export const sanitize = DOMPurify.sanitize.bind(DOMPurify);
17+
export const sanitize = ((dirty: any, config?: any) => purify.sanitize(dirty, config)) as DOMPurifyI['sanitize'];
1018
export const isSupported = DOMPurify.isSupported;
11-
export const addHook = DOMPurify.addHook.bind(DOMPurify);
12-
export const removeHook = DOMPurify.removeHook.bind(DOMPurify);
13-
export const removeHooks = DOMPurify.removeHooks.bind(DOMPurify);
14-
export const removeAllHooks = DOMPurify.removeAllHooks.bind(DOMPurify);
15-
export const setConfig = DOMPurify.setConfig.bind(DOMPurify);
16-
export const clearConfig = DOMPurify.clearConfig.bind(DOMPurify);
17-
export const isValidAttribute = DOMPurify.isValidAttribute.bind(DOMPurify);
19+
export const addHook = ((entryPoint: any, hookFunction: any) => purify.addHook(entryPoint, hookFunction)) as DOMPurifyI['addHook'];
20+
export const removeHook = ((entryPoint: any) => purify.removeHook(entryPoint)) as DOMPurifyI['removeHook'];
21+
export const removeHooks = ((entryPoint: any) => purify.removeHooks(entryPoint)) as DOMPurifyI['removeHooks'];
22+
export const removeAllHooks = (() => purify.removeAllHooks()) as DOMPurifyI['removeAllHooks'];
23+
export const setConfig = ((config: any) => purify.setConfig(config)) as DOMPurifyI['setConfig'];
24+
export const clearConfig = (() => purify.clearConfig()) as DOMPurifyI['clearConfig'];
25+
export const isValidAttribute = ((tag: any, attr: any, value: any) => purify.isValidAttribute(tag, attr, value)) as DOMPurifyI['isValidAttribute'];
1826
export const version = DOMPurify.version;
19-
export const removed = DOMPurify.removed;
27+
export const removed: DOMPurifyI['removed'] = new Proxy([] as DOMPurifyI['removed'], {
28+
get(_, prop) {
29+
return Reflect.get(purify.removed, prop);
30+
},
31+
});
32+
33+
export function clearWindow(): void {
34+
window.close();
35+
window = new JSDOM('<!DOCTYPE html>').window;
36+
purify = DOMPurifyFactory(window as unknown as Parameters<typeof DOMPurifyFactory>[0]);
37+
}

tests/clearWindow.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { expect, test } from "vitest";
2+
import { sanitize, clearWindow, removed } from "../src/index";
3+
4+
const CHUNK = 500;
5+
const ROUNDS = 5;
6+
const DIRTY_HTML = `<div onclick="alert(1)"><script>xss</script><b>safe</b></div>`;
7+
8+
function heapMB(): number {
9+
global.gc?.();
10+
return process.memoryUsage().heapUsed / 1024 / 1024;
11+
}
12+
13+
function runChunk(n: number): { avgMs: number } {
14+
const start = performance.now();
15+
for (let i = 0; i < n; i++) {
16+
sanitize(DIRTY_HTML);
17+
}
18+
return { avgMs: (performance.now() - start) / n };
19+
}
20+
21+
// Yield to the event loop so GC can sweep closed jsdom windows
22+
function tick(): Promise<void> {
23+
return new Promise((resolve) => setTimeout(resolve, 0));
24+
}
25+
26+
test("clearWindow resets heap growth and prevents time degradation", { timeout: 60_000 }, async () => {
27+
// Warm up
28+
runChunk(50);
29+
30+
// --- Run WITHOUT clearing ---
31+
const noClearHeaps: number[] = [];
32+
const noClearTimes: number[] = [];
33+
34+
for (let r = 0; r < ROUNDS; r++) {
35+
const { avgMs } = runChunk(CHUNK);
36+
noClearTimes.push(avgMs);
37+
noClearHeaps.push(heapMB());
38+
}
39+
40+
const noClearHeapGrowth = noClearHeaps[ROUNDS - 1] - noClearHeaps[0];
41+
const noClearTimeRatio = noClearTimes[ROUNDS - 1] / noClearTimes[0];
42+
43+
// Reset before the next phase
44+
clearWindow();
45+
await tick();
46+
global.gc?.();
47+
await tick();
48+
49+
// --- Run WITH clearing between rounds ---
50+
const withClearHeaps: number[] = [];
51+
const withClearTimes: number[] = [];
52+
53+
for (let r = 0; r < ROUNDS; r++) {
54+
const { avgMs } = runChunk(CHUNK);
55+
withClearTimes.push(avgMs);
56+
clearWindow();
57+
await tick();
58+
withClearHeaps.push(heapMB());
59+
}
60+
61+
const withClearHeapGrowth = withClearHeaps[ROUNDS - 1] - withClearHeaps[0];
62+
const withClearTimeRatio = withClearTimes[ROUNDS - 1] / withClearTimes[0];
63+
64+
// --- Report ---
65+
console.table({
66+
"Without clearWindow": {
67+
"heap start (MB)": noClearHeaps[0].toFixed(1),
68+
"heap end (MB)": noClearHeaps[ROUNDS - 1].toFixed(1),
69+
"heap growth (MB)": noClearHeapGrowth.toFixed(1),
70+
"avg ms/call first round": noClearTimes[0].toFixed(3),
71+
"avg ms/call last round": noClearTimes[ROUNDS - 1].toFixed(3),
72+
"time ratio (last/first)": noClearTimeRatio.toFixed(2),
73+
},
74+
"With clearWindow": {
75+
"heap start (MB)": withClearHeaps[0].toFixed(1),
76+
"heap end (MB)": withClearHeaps[ROUNDS - 1].toFixed(1),
77+
"heap growth (MB)": withClearHeapGrowth.toFixed(1),
78+
"avg ms/call first round": withClearTimes[0].toFixed(3),
79+
"avg ms/call last round": withClearTimes[ROUNDS - 1].toFixed(3),
80+
"time ratio (last/first)": withClearTimeRatio.toFixed(2),
81+
},
82+
});
83+
84+
// Without clearing, per-call time should degrade noticeably (>1.5x)
85+
expect(noClearTimeRatio).toBeGreaterThan(1.5);
86+
87+
// With clearing, per-call time should stay roughly stable (<1.5x)
88+
expect(withClearTimeRatio).toBeLessThan(1.5);
89+
90+
// With clearing, heap growth should be significantly less (only reliable with --expose-gc)
91+
if (global.gc) {
92+
expect(withClearHeapGrowth).toBeLessThan(noClearHeapGrowth);
93+
}
94+
});
95+
96+
test("sanitize works correctly after clearWindow", () => {
97+
const before = sanitize(DIRTY_HTML);
98+
clearWindow();
99+
const after = sanitize(DIRTY_HTML);
100+
101+
expect(before).toBe("<div><b>safe</b></div>");
102+
expect(after).toBe(before);
103+
});
104+
105+
test("removed named export reflects new instance after clearWindow", () => {
106+
sanitize('<img src="x" onerror="alert(1)">');
107+
expect(removed.length).toBeGreaterThan(0);
108+
109+
clearWindow();
110+
111+
// After clearing, removed should be empty (fresh instance, no sanitize calls yet)
112+
expect(removed.length).toBe(0);
113+
114+
// After sanitizing on the new instance, removed should update
115+
sanitize('<img src="x" onerror="alert(1)">');
116+
expect(removed.length).toBeGreaterThan(0);
117+
});

0 commit comments

Comments
 (0)