Skip to content

Commit 5e0c590

Browse files
refactor: merge common utils into one file
1 parent 767e158 commit 5e0c590

9 files changed

Lines changed: 285 additions & 294 deletions

File tree

src/middleware.js

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const mime = require("mime-types");
44

55
const onFinishedStream = require("on-finished");
66

7+
const { escapeHtml, etag, memorize, parseTokenList } = require("./utils");
78
const {
89
createReadStreamOrReadFileSync,
910
finish,
@@ -24,7 +25,6 @@ const {
2425
setStatusCode,
2526
} = require("./utils/compatibleAPI");
2627
const getFilenameFromUrl = require("./utils/getFilenameFromUrl");
27-
const memorize = require("./utils/memorize");
2828
const ready = require("./utils/ready");
2929

3030
/** @typedef {import("./index.js").NextFunction} NextFunction */
@@ -117,10 +117,6 @@ const parseRangeHeaders = memorize(
117117
},
118118
);
119119

120-
const getETag = memorize(() => require("./utils/etag"));
121-
const getEscapeHtml = memorize(() => require("./utils/escapeHtml"));
122-
const getParseTokenList = memorize(() => require("./utils/parseTokenList"));
123-
124120
const MAX_MAX_AGE = 31536000000;
125121

126122
/**
@@ -198,8 +194,6 @@ function wrapper(context) {
198194
return;
199195
}
200196

201-
const escapeHtml = getEscapeHtml();
202-
203197
const content = statuses[status] || String(status);
204198
let document = Buffer.from(
205199
`<!DOCTYPE html>
@@ -305,7 +299,7 @@ function wrapper(context) {
305299
return (
306300
!etag ||
307301
(ifMatch !== "*" &&
308-
getParseTokenList()(ifMatch).every(
302+
parseTokenList(ifMatch).every(
309303
(match) =>
310304
match !== etag &&
311305
match !== `W/${etag}` &&
@@ -383,7 +377,7 @@ function wrapper(context) {
383377
return false;
384378
}
385379

386-
const matches = getParseTokenList()(noneMatch);
380+
const matches = parseTokenList(noneMatch);
387381

388382
let etagStale = true;
389383

@@ -681,7 +675,7 @@ function wrapper(context) {
681675
}
682676
}
683677

684-
const result = await getETag()(
678+
const result = await etag(
685679
isStrongETag
686680
? /** @type {Buffer | ReadStream} */ (bufferOrStream)
687681
: extra.stats,

src/utils.js

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
const crypto = require("node:crypto");
2+
3+
const matchHtmlRegExp = /["'&<>]/;
4+
5+
/**
6+
* @param {string} string raw HTML
7+
* @returns {string} escaped HTML
8+
*/
9+
function escapeHtml(string) {
10+
const str = `${string}`;
11+
const match = matchHtmlRegExp.exec(str);
12+
13+
if (!match) {
14+
return str;
15+
}
16+
17+
let escape;
18+
let html = "";
19+
let index = 0;
20+
let lastIndex = 0;
21+
22+
for ({ index } = match; index < str.length; index++) {
23+
switch (str.charCodeAt(index)) {
24+
// "
25+
case 34:
26+
escape = "&quot;";
27+
break;
28+
// &
29+
case 38:
30+
escape = "&amp;";
31+
break;
32+
// '
33+
case 39:
34+
escape = "&#39;";
35+
break;
36+
// <
37+
case 60:
38+
escape = "&lt;";
39+
break;
40+
// >
41+
case 62:
42+
escape = "&gt;";
43+
break;
44+
default:
45+
continue;
46+
}
47+
48+
if (lastIndex !== index) {
49+
// eslint-disable-next-line unicorn/prefer-string-slice
50+
html += str.substring(lastIndex, index);
51+
}
52+
53+
lastIndex = index + 1;
54+
html += escape;
55+
}
56+
57+
// eslint-disable-next-line unicorn/prefer-string-slice
58+
return lastIndex !== index ? html + str.substring(lastIndex, index) : html;
59+
}
60+
61+
/** @typedef {import("fs").Stats} Stats */
62+
/** @typedef {import("fs").ReadStream} ReadStream */
63+
64+
/**
65+
* Generate a tag for a stat.
66+
* @param {Stats} stats stats
67+
* @returns {{ hash: string, buffer?: Buffer }} etag
68+
*/
69+
function statTag(stats) {
70+
const mtime = stats.mtime.getTime().toString(16);
71+
const size = stats.size.toString(16);
72+
73+
return { hash: `W/"${size}-${mtime}"` };
74+
}
75+
76+
/**
77+
* Generate an entity tag.
78+
* @param {Buffer | ReadStream} entity entity
79+
* @returns {Promise<{ hash: string, buffer?: Buffer }>} etag
80+
*/
81+
async function entityTag(entity) {
82+
const sha1 = crypto.createHash("sha1");
83+
84+
if (!Buffer.isBuffer(entity)) {
85+
let byteLength = 0;
86+
87+
/** @type {Buffer[]} */
88+
const buffers = [];
89+
90+
await new Promise((resolve, reject) => {
91+
entity
92+
.on("data", (chunk) => {
93+
sha1.update(chunk);
94+
buffers.push(/** @type {Buffer} */ (chunk));
95+
byteLength += /** @type {Buffer} */ (chunk).byteLength;
96+
})
97+
.on("end", () => {
98+
resolve(sha1);
99+
})
100+
.on("error", reject);
101+
});
102+
103+
return {
104+
buffer: Buffer.concat(buffers),
105+
hash: `"${byteLength.toString(16)}-${sha1.digest("base64").slice(0, 27)}"`,
106+
};
107+
}
108+
109+
if (entity.byteLength === 0) {
110+
// Fast-path empty
111+
return { hash: '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"' };
112+
}
113+
114+
// Compute hash of entity
115+
const hash = sha1.update(entity).digest("base64").slice(0, 27);
116+
117+
// Compute length of entity
118+
const { byteLength } = entity;
119+
120+
return { hash: `"${byteLength.toString(16)}-${hash}"` };
121+
}
122+
123+
/**
124+
* Create a simple ETag.
125+
* @param {Buffer | ReadStream | Stats} entity entity
126+
* @returns {Promise<{ hash: string, buffer?: Buffer }>} etag
127+
*/
128+
async function etag(entity) {
129+
const isStrong =
130+
Buffer.isBuffer(entity) ||
131+
typeof (/** @type {ReadStream} */ (entity).pipe) === "function";
132+
133+
return isStrong
134+
? entityTag(/** @type {Buffer | ReadStream} */ (entity))
135+
: statTag(/** @type {import("fs").Stats} */ (entity));
136+
}
137+
138+
/** @typedef {import("./index").EXPECTED_ANY} EXPECTED_ANY */
139+
140+
const cacheStore = new WeakMap();
141+
142+
/**
143+
* @template T
144+
* @typedef {(...args: EXPECTED_ANY) => T} FunctionReturning
145+
*/
146+
147+
/**
148+
* @template T
149+
* @param {FunctionReturning<T>} fn memorized function
150+
* @param {({ cache?: Map<string, { data: T }> } | undefined)=} cache cache
151+
* @param {((value: T) => T)=} callback callback
152+
* @returns {FunctionReturning<T>} new function
153+
*/
154+
function memorize(fn, { cache = new Map() } = {}, callback = undefined) {
155+
/**
156+
* @param {EXPECTED_ANY[]} arguments_ args
157+
* @returns {EXPECTED_ANY} result
158+
*/
159+
const memoized = (...arguments_) => {
160+
const [key] = arguments_;
161+
const cacheItem = cache.get(key);
162+
163+
if (cacheItem) {
164+
return cacheItem.data;
165+
}
166+
167+
// @ts-expect-error
168+
let result = fn.apply(this, arguments_);
169+
170+
if (callback) {
171+
result = callback(result);
172+
}
173+
174+
cache.set(key, {
175+
data: result,
176+
});
177+
178+
return result;
179+
};
180+
181+
cacheStore.set(memoized, cache);
182+
183+
return memoized;
184+
}
185+
186+
/**
187+
* Parse a HTTP token list.
188+
* @param {string} str str
189+
* @returns {string[]} tokens
190+
*/
191+
function parseTokenList(str) {
192+
let end = 0;
193+
let start = 0;
194+
195+
const list = [];
196+
197+
// gather tokens
198+
for (let i = 0, len = str.length; i < len; i++) {
199+
switch (str.charCodeAt(i)) {
200+
case 0x20 /* */:
201+
if (start === end) {
202+
end = i + 1;
203+
start = end;
204+
}
205+
break;
206+
case 0x2c /* , */:
207+
if (start !== end) {
208+
list.push(str.slice(start, end));
209+
}
210+
end = i + 1;
211+
start = end;
212+
break;
213+
default:
214+
end = i + 1;
215+
break;
216+
}
217+
}
218+
219+
// final token
220+
if (start !== end) {
221+
list.push(str.slice(start, end));
222+
}
223+
224+
return list;
225+
}
226+
227+
module.exports = {
228+
escapeHtml,
229+
etag,
230+
memorize,
231+
parseTokenList,
232+
};

src/utils/escapeHtml.js

Lines changed: 0 additions & 59 deletions
This file was deleted.

0 commit comments

Comments
 (0)