Skip to content

Commit 1258fdd

Browse files
fix: cleaup stream and handle errors (#1769)
1 parent 22ec9ad commit 1258fdd

File tree

8 files changed

+401
-6
lines changed

8 files changed

+401
-6
lines changed

.cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"myhtml",
1818
"configurated",
1919
"mycustom",
20-
"commitlint"
20+
"commitlint",
21+
"nosniff"
2122
],
2223
"ignorePaths": [
2324
"CHANGELOG.md",

package-lock.json

Lines changed: 12 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"colorette": "^2.0.10",
5757
"memfs": "^4.6.0",
5858
"mime-types": "^2.1.31",
59+
"on-finished": "^2.4.1",
5960
"range-parser": "^1.2.1",
6061
"schema-utils": "^4.0.0"
6162
},
@@ -69,6 +70,7 @@
6970
"@types/express": "^4.17.13",
7071
"@types/mime-types": "^2.1.1",
7172
"@types/node": "^20.11.16",
73+
"@types/on-finished": "^2.3.4",
7274
"@webpack-contrib/eslint-config-webpack": "^3.0.0",
7375
"babel-jest": "^29.3.1",
7476
"chokidar": "^3.5.1",

src/utils/compatibleAPI.js

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
const onFinishedStream = require("on-finished");
2+
3+
const escapeHtml = require("./escapeHtml");
4+
15
/** @typedef {import("../index.js").IncomingMessage} IncomingMessage */
26
/** @typedef {import("../index.js").ServerResponse} ServerResponse */
37

@@ -88,6 +92,18 @@ function setHeaderForResponse(res, name, value) {
8892
res.setHeader(name, value);
8993
}
9094

95+
/**
96+
* @template {ServerResponse} Response
97+
* @param {Response} res
98+
*/
99+
function clearHeadersForResponse(res) {
100+
const headers = getHeaderNames(res);
101+
102+
for (let i = 0; i < headers.length; i++) {
103+
res.removeHeader(headers[i]);
104+
}
105+
}
106+
91107
/**
92108
* @template {ServerResponse} Response
93109
* @param {Response} res
@@ -108,6 +124,76 @@ function setStatusCode(res, code) {
108124
res.statusCode = code;
109125
}
110126

127+
/**
128+
* @param {import("fs").ReadStream} stream stream
129+
* @param {boolean} suppress do need suppress?
130+
* @returns {void}
131+
*/
132+
function destroyStream(stream, suppress) {
133+
if (typeof stream.destroy === "function") {
134+
stream.destroy();
135+
}
136+
137+
if (typeof stream.close === "function") {
138+
// Node.js core bug workaround
139+
stream.on(
140+
"open",
141+
/**
142+
* @this {import("fs").ReadStream}
143+
*/
144+
function onOpenClose() {
145+
// @ts-ignore
146+
if (typeof this.fd === "number") {
147+
// actually close down the fd
148+
this.close();
149+
}
150+
},
151+
);
152+
}
153+
154+
if (typeof stream.addListener === "function" && suppress) {
155+
stream.removeAllListeners("error");
156+
stream.addListener("error", () => {});
157+
}
158+
}
159+
160+
/** @type {Record<number, string>} */
161+
const statuses = {
162+
404: "Not Found",
163+
500: "Internal Server Error",
164+
};
165+
166+
/**
167+
* @template {ServerResponse} Response
168+
* @param {Response} res response
169+
* @param {number} status status
170+
* @returns {void}
171+
*/
172+
function sendError(res, status) {
173+
const msg = statuses[status] || String(status);
174+
const doc = `<!DOCTYPE html>
175+
<html lang="en">
176+
<head>
177+
<meta charset="utf-8">
178+
<title>Error</title>
179+
</head>
180+
<body>
181+
<pre>${escapeHtml(msg)}</pre>
182+
</body>
183+
</html>`;
184+
185+
// Clear existing headers
186+
clearHeadersForResponse(res);
187+
// Send basic response
188+
setStatusCode(res, status);
189+
setHeaderForResponse(res, "Content-Type", "text/html; charset=UTF-8");
190+
setHeaderForResponse(res, "Content-Length", Buffer.byteLength(doc));
191+
setHeaderForResponse(res, "Content-Security-Policy", "default-src 'none'");
192+
setHeaderForResponse(res, "X-Content-Type-Options", "nosniff");
193+
194+
res.end(doc);
195+
}
196+
111197
/**
112198
* @template {IncomingMessage} Request
113199
* @template {ServerResponse} Response
@@ -125,13 +211,42 @@ function send(req, res, bufferOtStream, byteLength) {
125211

126212
if (req.method === "HEAD") {
127213
res.end();
128-
129214
return;
130215
}
131216

132217
/** @type {import("fs").ReadStream} */
133218
(bufferOtStream).pipe(res);
134219

220+
// Cleanup
221+
const cleanup = () => {
222+
destroyStream(
223+
/** @type {import("fs").ReadStream} */ (bufferOtStream),
224+
true,
225+
);
226+
};
227+
228+
// Response finished, cleanup
229+
onFinishedStream(res, cleanup);
230+
231+
// error handling
232+
/** @type {import("fs").ReadStream} */
233+
(bufferOtStream).on("error", (error) => {
234+
// clean up stream early
235+
cleanup();
236+
237+
// Handle Error
238+
switch (/** @type {NodeJS.ErrnoException} */ (error).code) {
239+
case "ENAMETOOLONG":
240+
case "ENOENT":
241+
case "ENOTDIR":
242+
sendError(res, 404);
243+
break;
244+
default:
245+
sendError(res, 500);
246+
break;
247+
}
248+
});
249+
135250
return;
136251
}
137252

@@ -141,7 +256,6 @@ function send(req, res, bufferOtStream, byteLength) {
141256
) {
142257
/** @type {Response & ExpectedResponse} */
143258
(res).send(bufferOtStream);
144-
145259
return;
146260
}
147261

src/utils/escapeHtml.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const matchHtmlRegExp = /["'&<>]/;
2+
3+
/**
4+
* @param {string} string raw HTML
5+
* @returns {string} escaped HTML
6+
*/
7+
function escapeHtml(string) {
8+
const str = `${string}`;
9+
const match = matchHtmlRegExp.exec(str);
10+
11+
if (!match) {
12+
return str;
13+
}
14+
15+
let escape;
16+
let html = "";
17+
let index = 0;
18+
let lastIndex = 0;
19+
20+
for ({ index } = match; index < str.length; index++) {
21+
switch (str.charCodeAt(index)) {
22+
// "
23+
case 34:
24+
escape = "&quot;";
25+
break;
26+
// &
27+
case 38:
28+
escape = "&amp;";
29+
break;
30+
// '
31+
case 39:
32+
escape = "&#39;";
33+
break;
34+
// <
35+
case 60:
36+
escape = "&lt;";
37+
break;
38+
// >
39+
case 62:
40+
escape = "&gt;";
41+
break;
42+
default:
43+
// eslint-disable-next-line no-continue
44+
continue;
45+
}
46+
47+
if (lastIndex !== index) {
48+
html += str.substring(lastIndex, index);
49+
}
50+
51+
lastIndex = index + 1;
52+
html += escape;
53+
}
54+
55+
return lastIndex !== index ? html + str.substring(lastIndex, index) : html;
56+
}
57+
58+
module.exports = escapeHtml;

0 commit comments

Comments
 (0)