From 27fa75ff6bf2af732a8ee7f98f5f03a560517a41 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 19 Mar 2026 02:12:14 +0300 Subject: [PATCH 1/6] fix: update logic for URLs ending with '/' --- src/middleware.js | 174 +++++++++++++++++++++++++++++++--------------- 1 file changed, 118 insertions(+), 56 deletions(-) diff --git a/src/middleware.js b/src/middleware.js index 2ad60c462..45c12ba09 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -58,9 +58,11 @@ const memoizedParse = memorize((url) => { const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; +/** @typedef {import("fs").Stats} FSStats */ + /** * @typedef {object} Extra - * @property {import("fs").Stats} stats stats + * @property {FSStats} stats stats * @property {boolean=} immutable true when immutable, otherwise false * @property {OutputFileSystem} outputFileSystem outputFileSystem */ @@ -81,13 +83,27 @@ class FilenameError extends Error { constructor(message, code) { super(message); this.name = "FilenameError"; - this.code = code; + this.statusCode = code; } } /** @typedef {{ filename: string, extra: Extra }} FilenameWithExtra */ -// TODO fix redirect logic when `/` at the end, like https://github.com/pillarjs/send/blob/master/index.js#L586 +/** + * @param {unknown} error error + * @returns {boolean} true when error is like not found, otherwise false + */ +function isNotFoundError(error) { + switch (/** @type {NodeJS.ErrnoException} */ (error).code) { + case "ENAMETOOLONG": + case "ENOENT": + case "ENOTDIR": + return true; + default: + return false; + } +} + /** * @template {IncomingMessage} Request * @template {ServerResponse} Response @@ -99,9 +115,6 @@ function getFilenameFromUrl(context, url) { /** @type {URL} */ let urlObject; - /** @type {string | undefined} */ - let foundFilename; - try { // The `url` property of the `request` is contains only `pathname`, `search` and `hash` urlObject = memoizedParse(url); @@ -110,14 +123,19 @@ function getFilenameFromUrl(context, url) { } const { options, stats } = context; - /** @type {Extra} */ - const extra = {}; /** @type {Stats[]} */ const allStats = /** @type {MultiStats} */ (stats).stats || [/** @type {Stats} */ (stats)]; + const index = + options.index === false + ? /** @type {string[]} */ ([]) + : typeof options.index === "undefined" || options.index === true + ? ["index.html"] + : [options.index]; + for (const { compilation } of allStats) { if (compilation.options.devServer === false) { continue; @@ -158,6 +176,7 @@ function getFilenameFromUrl(context, url) { throw new FilenameError("Forbidden", 403); } + // send file logic // The `output.path` is always present and always absolute const outputPath = compilation.getPath( compilation.outputOptions.path || "", @@ -172,61 +191,100 @@ function getFilenameFromUrl(context, url) { pathname.slice(publicPathPathname.length), ); + const { assetsInfo } = compilation; const { outputFileSystem } = /** @type {Compiler & { outputFileSystem: OutputFileSystem }} */ (compilation.compiler); - try { - extra.stats = outputFileSystem.statSync(filename); - } catch { - continue; - } - - const { assetsInfo } = compilation; + /** + * @param {string} filename filename + * @returns {FilenameWithExtra | undefined} filename when found, otherwise undefined + */ + const resolveIndex = (filename) => { + filename = path.join(filename, index[0]); - if (extra.stats.isFile()) { - foundFilename = filename; + let stats; - // Rspack does not yet support `assetsInfo`, so we need to check if `assetsInfo` exists here - extra.immutable = assetsInfo - ? assetsInfo.get(pathname.slice(publicPathPathname.length))?.immutable - : false; - extra.outputFileSystem = outputFileSystem; + try { + stats = outputFileSystem.statSync(filename); + } catch (err) { + if (isNotFoundError(err)) return; + throw err; + } - break; - } else if ( - extra.stats.isDirectory() && - (typeof options.index === "undefined" || options.index) - ) { - const indexValue = - typeof options.index === "undefined" || - typeof options.index === "boolean" - ? "index.html" - : options.index; + if (/** @type {FSStats} */ (stats).isDirectory()) { + return resolveIndex(filename); + } - filename = path.join(filename, indexValue); + const extra = { + immutable: assetsInfo + ? assetsInfo.get(pathname.slice(publicPathPathname.length)) + ?.immutable + : false, + outputFileSystem, + stats: /** @type {FSStats} */ (stats), + }; + + return { filename, extra }; + }; + + /** + * @param {string} filename filename + * @returns {FilenameWithExtra | undefined} filename when found, otherwise undefined + */ + const resolveFile = (filename) => { + let stats; try { - extra.stats = outputFileSystem.statSync(filename); - } catch { - continue; + stats = outputFileSystem.statSync(filename); + } catch (err) { + if (isNotFoundError(err)) return; + throw err; } - if (extra.stats.isFile()) { - foundFilename = filename; - extra.outputFileSystem = outputFileSystem; + if (/** @type {FSStats} */ (stats).isDirectory()) { + // Different between `send` and our logic is here, `send` makes a redirect, we just return a file. + return resolveIndex(filename); + } - break; + if (filename.endsWith(path.sep)) { + return; } + + /** @type {Extra} */ + const extra = { + immutable: assetsInfo + ? assetsInfo.get(pathname.slice(publicPathPathname.length)) + ?.immutable + : false, + outputFileSystem, + stats: /** @type {FSStats} */ (stats), + }; + + return { filename, extra }; + }; + + // send index logic + if (index.length > 0 && pathname.endsWith("/")) { + const result = resolveIndex(filename); + + if (!result) { + continue; + } + + return result; } - } - } - if (!foundFilename) { - return; - } + // send file logic + const result = resolveFile(filename); + + if (!result) { + continue; + } - return { filename: foundFilename, extra }; + return result; + } + } } /** @@ -448,9 +506,11 @@ function wrapper(context) { /** * @param {NodeJS.ErrnoException} error error + * @param {string=} message override message + * @param {number=} code override code * @returns {Promise} */ - async function errorHandler(error) { + async function errorHandler(error, message, code) { switch (error.code) { case "ENAMETOOLONG": case "ENOENT": @@ -460,7 +520,7 @@ function wrapper(context) { }); break; default: - await sendError(error.message, 500, { + await sendError(message || error.message, code || 500, { modifyResponseData: context.options.modifyResponseData, }); break; @@ -713,20 +773,22 @@ function wrapper(context) { const errorCode = typeof err === "object" && err !== null && - typeof (/** @type {FilenameError} */ (err).code) !== "undefined" - ? /** @type {FilenameError} */ (err).code - : 403; + typeof (/** @type {FilenameError} */ (err).statusCode) !== "undefined" + ? /** @type {FilenameError} */ (err).statusCode + : undefined; if (errorCode === 403) { context.logger.error(`Malicious path "${requestUrl}".`); } - await sendError( - errorCode === 400 ? "Bad Request" : "Forbidden", + await errorHandler( + /** @type {NodeJS.ErrnoException} */ (err), + errorCode === 400 + ? "Bad Request" + : errorCode === 403 + ? "Forbidden" + : undefined, errorCode, - { - modifyResponseData: context.options.modifyResponseData, - }, ); return; } From 21bdbd004db23379603a9f550a7e558724b99c1f Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 19 Mar 2026 15:34:34 +0300 Subject: [PATCH 2/6] test: fix --- src/middleware.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/middleware.js b/src/middleware.js index 45c12ba09..59c7f2c27 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -201,6 +201,10 @@ function getFilenameFromUrl(context, url) { * @returns {FilenameWithExtra | undefined} filename when found, otherwise undefined */ const resolveIndex = (filename) => { + if (index.length === 0) { + return; + } + filename = path.join(filename, index[0]); let stats; From 522d1d5574d5f6700cba9e774b516df16d529f5a Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 19 Mar 2026 16:22:01 +0300 Subject: [PATCH 3/6] test: more --- .changeset/late-snails-argue.md | 2 +- README.md | 24 +- src/index.js | 4 +- src/middleware.js | 18 +- test/middleware.test.js | 494 +++++++++++++++++++------------- types/index.d.ts | 13 +- types/middleware.d.ts | 9 +- 7 files changed, 325 insertions(+), 239 deletions(-) diff --git a/.changeset/late-snails-argue.md b/.changeset/late-snails-argue.md index 242e07141..c46dc080e 100644 --- a/.changeset/late-snails-argue.md +++ b/.changeset/late-snails-argue.md @@ -2,4 +2,4 @@ "webpack-dev-middleware": major --- -The `getFilenameFromUrl` function now returns an object with the found `filename` (or `undefined` if the file was not found) and throws an error if the URL cannot be processed. Additionally, the object contains the `extra` property with `stats` (file system stats) and `outputFileSystem` (output file system where file was found) properties. +The `getFilenameFromUrl` function is now asynchronous, returning a Promise that resolves to the object with the found `filename` (or `undefined` if the file was not found) or throws an error if the URL cannot be processed. Additionally, the object contains the `extra` property with `stats` (file system stats) and `outputFileSystem` (output file system where file was found) properties. diff --git a/README.md b/README.md index 823f108fa..d688aa5e1 100644 --- a/README.md +++ b/README.md @@ -460,20 +460,16 @@ const app = new express(); app.use(instance); instance.waitUntilValid(() => { - let resolver; - - try { - resolved = instance.getFilenameFromUrl("/bundle.js"); - } catch (err) { - console.log(`Error: ${err}`); - } - - if (!resolved) { - console.log("Not found"); - return; - } - - console.log(`Filename is ${filename}`); + instance + .getFilenameFromUrl("/bundle.js") + .then(() => { + if (filename) { + console.log(`Filename is ${filename}`); + } + }) + .catch((err) => { + console.log(`Error: ${err}`); + }); }); ``` diff --git a/src/index.js b/src/index.js index 23be7bbe7..c9e5794ca 100644 --- a/src/index.js +++ b/src/index.js @@ -14,7 +14,7 @@ const noop = () => {}; /** @typedef {import("webpack").Stats} Stats */ /** @typedef {import("webpack").MultiStats} MultiStats */ /** @typedef {import("fs").ReadStream} ReadStream */ -/** @typedef {import("./middleware").Extra} Extra */ +/** @typedef {import("./middleware").FilenameWithExtra} FilenameWithExtra */ // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ @@ -126,7 +126,7 @@ const noop = () => {}; /** * @callback GetFilenameFromUrl * @param {string} url request URL - * @returns {{ filename: string, extra: Extra } | undefined} a filename with additional information, or `undefined` if nothing is found + * @returns {Promise} a filename with additional information, or `undefined` if nothing is found */ /** diff --git a/src/middleware.js b/src/middleware.js index 59c7f2c27..38586c106 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -109,9 +109,9 @@ function isNotFoundError(error) { * @template {ServerResponse} Response * @param {import("./index.js").FilledContext} context context * @param {string} url url - * @returns {FilenameWithExtra | undefined} result of get filename from url + * @returns {Promise} result of get filename from url */ -function getFilenameFromUrl(context, url) { +async function getFilenameFromUrl(context, url) { /** @type {URL} */ let urlObject; @@ -198,9 +198,9 @@ function getFilenameFromUrl(context, url) { /** * @param {string} filename filename - * @returns {FilenameWithExtra | undefined} filename when found, otherwise undefined + * @returns {Promise} filename when found, otherwise undefined */ - const resolveIndex = (filename) => { + const resolveIndex = async (filename) => { if (index.length === 0) { return; } @@ -234,9 +234,9 @@ function getFilenameFromUrl(context, url) { /** * @param {string} filename filename - * @returns {FilenameWithExtra | undefined} filename when found, otherwise undefined + * @returns {Promise} filename when found, otherwise undefined */ - const resolveFile = (filename) => { + const resolveFile = async (filename) => { let stats; try { @@ -270,7 +270,7 @@ function getFilenameFromUrl(context, url) { // send index logic if (index.length > 0 && pathname.endsWith("/")) { - const result = resolveIndex(filename); + const result = await resolveIndex(filename); if (!result) { continue; @@ -280,7 +280,7 @@ function getFilenameFromUrl(context, url) { } // send file logic - const result = resolveFile(filename); + const result = await resolveFile(filename); if (!result) { continue; @@ -771,7 +771,7 @@ function wrapper(context) { const requestUrl = /** @type {string} */ (getRequestURL(req)); try { - resolved = getFilenameFromUrl(context, requestUrl); + resolved = await getFilenameFromUrl(context, requestUrl); } catch (err) { // Fallback to 403 for unknown errors const errorCode = diff --git a/test/middleware.test.js b/test/middleware.test.js index 44e311cd6..19d743926 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -572,26 +572,33 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/bundle.js").filename).toBe( - path.join(webpackConfig.output.path, "/bundle.js"), - ); - expect(instance.getFilenameFromUrl("/").filename).toBe( - path.join(webpackConfig.output.path, "/index.html"), - ); - expect(instance.getFilenameFromUrl("/index.html").filename).toBe( - path.join(webpackConfig.output.path, "/index.html"), - ); - expect(instance.getFilenameFromUrl("/svg.svg").filename).toBe( - path.join(webpackConfig.output.path, "/svg.svg"), - ); - expect( + Promise.all([ + instance.getFilenameFromUrl("/bundle.js"), + instance.getFilenameFromUrl("/"), + instance.getFilenameFromUrl("/index.html"), + instance.getFilenameFromUrl("/svg.svg"), instance.getFilenameFromUrl("/unknown.unknown"), - ).toBeUndefined(); - expect( instance.getFilenameFromUrl("/unknown/unknown.unknown"), - ).toBeUndefined(); + ]) + .then(([bundle, root, index, svg, unknown1, unknown2]) => { + expect(bundle.filename).toBe( + path.join(webpackConfig.output.path, "/bundle.js"), + ); + expect(root.filename).toBe( + path.join(webpackConfig.output.path, "/index.html"), + ); + expect(index.filename).toBe( + path.join(webpackConfig.output.path, "/index.html"), + ); + expect(svg.filename).toBe( + path.join(webpackConfig.output.path, "/svg.svg"), + ); + expect(unknown1).toBeUndefined(); + expect(unknown2).toBeUndefined(); - done(); + done(); + }) + .catch(done); }); }); }); @@ -616,25 +623,34 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/bundle.js").filename).toBe( - path.join(webpackConfig.output.path, "/bundle.js"), - ); - - expect(instance.getFilenameFromUrl("/")).toBeUndefined(); - expect(instance.getFilenameFromUrl("/index.html").filename).toBe( - path.join(webpackConfig.output.path, "/index.html"), - ); - expect(instance.getFilenameFromUrl("/svg.svg").filename).toBe( - path.join(webpackConfig.output.path, "/svg.svg"), - ); - expect( + Promise.all([ + instance.getFilenameFromUrl("/bundle.js"), + instance.getFilenameFromUrl("/"), + instance.getFilenameFromUrl("/index.html"), + instance.getFilenameFromUrl("/svg.svg"), instance.getFilenameFromUrl("/unknown.unknown"), - ).toBeUndefined(); - expect( instance.getFilenameFromUrl("/unknown/unknown.unknown"), - ).toBeUndefined(); + ]) + .then(([bundle, root, index, svg, unknown1, unknown2]) => { + expect(bundle.filename).toBe( + path.join(webpackConfig.output.path, "/bundle.js"), + ); - done(); + expect(root).toBeUndefined(); + + expect(index.filename).toBe( + path.join(webpackConfig.output.path, "/index.html"), + ); + expect(svg.filename).toBe( + path.join(webpackConfig.output.path, "/svg.svg"), + ); + + expect(unknown1).toBeUndefined(); + expect(unknown2).toBeUndefined(); + + done(); + }) + .catch(done); }); }); }); @@ -656,36 +672,50 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect( - instance.getFilenameFromUrl("/public/path/bundle.js").filename, - ).toBe( - path.join(webpackPublicPathConfig.output.path, "/bundle.js"), - ); - expect( - instance.getFilenameFromUrl("/public/path/").filename, - ).toBe( - path.join(webpackPublicPathConfig.output.path, "/index.html"), - ); - expect( - instance.getFilenameFromUrl("/public/path/index.html").filename, - ).toBe( - path.join(webpackPublicPathConfig.output.path, "/index.html"), - ); - expect( - instance.getFilenameFromUrl("/public/path/svg.svg").filename, - ).toBe( - path.join(webpackPublicPathConfig.output.path, "/svg.svg"), - ); - - expect(instance.getFilenameFromUrl("/")).toBeUndefined(); - expect( + Promise.all([ + instance.getFilenameFromUrl("/public/path/bundle.js"), + instance.getFilenameFromUrl("/public/path/"), + instance.getFilenameFromUrl("/public/path/index.html"), + instance.getFilenameFromUrl("/public/path/svg.svg"), + instance.getFilenameFromUrl("/"), instance.getFilenameFromUrl("/unknown.unknown"), - ).toBeUndefined(); - expect( instance.getFilenameFromUrl("/unknown/unknown.unknown"), - ).toBeUndefined(); + ]) + .then( + ([bundle, root, index, svg, home, unknown1, unknown2]) => { + expect(bundle.filename).toBe( + path.join( + webpackPublicPathConfig.output.path, + "/bundle.js", + ), + ); + expect(root.filename).toBe( + path.join( + webpackPublicPathConfig.output.path, + "/index.html", + ), + ); + expect(index.filename).toBe( + path.join( + webpackPublicPathConfig.output.path, + "/index.html", + ), + ); + expect(svg.filename).toBe( + path.join( + webpackPublicPathConfig.output.path, + "/svg.svg", + ), + ); - done(); + expect(home).toBeUndefined(); + expect(unknown1).toBeUndefined(); + expect(unknown2).toBeUndefined(); + + done(); + }, + ) + .catch(done); }); }); }); @@ -707,56 +737,77 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect( - instance.getFilenameFromUrl("/static-one/bundle.js").filename, - ).toBe( - path.join(webpackMultiConfig[0].output.path, "/bundle.js"), - ); - expect(instance.getFilenameFromUrl("/static-one/").filename).toBe( - path.join(webpackMultiConfig[0].output.path, "/index.html"), - ); - expect( - instance.getFilenameFromUrl("/static-one/index.html").filename, - ).toBe( - path.join(webpackMultiConfig[0].output.path, "/index.html"), - ); - expect( - instance.getFilenameFromUrl("/static-one/svg.svg").filename, - ).toBe(path.join(webpackMultiConfig[0].output.path, "/svg.svg")); - expect( + Promise.all([ + instance.getFilenameFromUrl("/static-one/bundle.js"), + instance.getFilenameFromUrl("/static-one/"), + instance.getFilenameFromUrl("/static-one/index.html"), + instance.getFilenameFromUrl("/static-one/svg.svg"), instance.getFilenameFromUrl("/static-one/unknown.unknown"), - ).toBeUndefined(); - expect( instance.getFilenameFromUrl( "/static-one/unknown/unknown.unknown", ), - ).toBeUndefined(); - - expect( - instance.getFilenameFromUrl("/static-two/bundle.js").filename, - ).toBe( - path.join(webpackMultiConfig[1].output.path, "/bundle.js"), - ); - expect( + instance.getFilenameFromUrl("/static-two/bundle.js"), instance.getFilenameFromUrl("/static-two/unknown.unknown"), - ).toBeUndefined(); - expect( instance.getFilenameFromUrl( "/static-two/unknown/unknown.unknown", ), - ).toBeUndefined(); + instance.getFilenameFromUrl("/"), + ]) + .then( + ([ + s1Bundle, + s1Root, + s1Index, + s1Svg, + s1Unk1, + s1Unk2, + s2Bundle, + s2Unk1, + s2Unk2, + root, + ]) => { + // Static One + expect(s1Bundle.filename).toBe( + path.join( + webpackMultiConfig[0].output.path, + "/bundle.js", + ), + ); + expect(s1Root.filename).toBe( + path.join( + webpackMultiConfig[0].output.path, + "/index.html", + ), + ); + expect(s1Index.filename).toBe( + path.join( + webpackMultiConfig[0].output.path, + "/index.html", + ), + ); + expect(s1Svg.filename).toBe( + path.join(webpackMultiConfig[0].output.path, "/svg.svg"), + ); + expect(s1Unk1).toBeUndefined(); + expect(s1Unk2).toBeUndefined(); + + // Static Two + expect(s2Bundle.filename).toBe( + path.join( + webpackMultiConfig[1].output.path, + "/bundle.js", + ), + ); + expect(s2Unk1).toBeUndefined(); + expect(s2Unk2).toBeUndefined(); - expect(instance.getFilenameFromUrl("/")).toBeUndefined(); - expect( - instance.getFilenameFromUrl("/static-one/unknown.unknown"), - ).toBeUndefined(); - expect( - instance.getFilenameFromUrl( - "/static-one/unknown/unknown.unknown", - ), - ).toBeUndefined(); + // General + expect(root).toBeUndefined(); - done(); + done(); + }, + ) + .catch(done); }); }); }); @@ -1296,39 +1347,6 @@ describe.each([ }); }); - describe('should work with the broken "publicPath" option (malformed URI parsed as "/")', () => { - let compiler; - - const outputPath = path.resolve(__dirname, "./outputs/basic"); - - beforeAll(async () => { - compiler = getCompiler({ - ...webpackConfig, - output: { - filename: "bundle.js", - path: outputPath, - publicPath: "http/s://test:malformed%5Med@test.example.com", - }, - }); - - [server, req, instance] = await frameworkFactory( - name, - framework, - compiler, - ); - }); - - afterAll(async () => { - await close(server, instance); - }); - - it('should return the "400" code for the "GET" request to the bundle file', async () => { - const response = await req.get("/bundle.js"); - - expect(response.statusCode).toBe(404); - }); - }); - describe("should work in multi-compiler mode", () => { beforeAll(async () => { const compiler = getCompiler(webpackMultiConfig); @@ -1789,6 +1807,47 @@ describe.each([ }, ], }, + { + file: "foo/index.html/index.html/index.html", + data: "
test
", + urls: [ + { + value: "foo", + contentType: "text/html; charset=utf-8", + code: 200, + }, + { + value: "foo/", + contentType: "text/html; charset=utf-8", + code: 200, + }, + { + value: "foo/index.html", + contentType: "text/html; charset=utf-8", + code: 200, + }, + { + value: "foo/index.html/", + contentType: "text/html; charset=utf-8", + code: 200, + }, + { + value: "foo/index.html/index.html", + contentType: "text/html; charset=utf-8", + code: 200, + }, + { + value: "foo/index.html/index.html/", + contentType: "text/html; charset=utf-8", + code: 200, + }, + { + value: "foo/index.html/index.html/index.html", + contentType: "text/html; charset=utf-8", + code: 200, + }, + ], + }, ]; const configurations = [ @@ -2145,6 +2204,39 @@ describe.each([ }); }); + describe('should work with the broken "publicPath" option (malformed URI parsed as "/")', () => { + let compiler; + + const outputPath = path.resolve(__dirname, "./outputs/basic"); + + beforeAll(async () => { + compiler = getCompiler({ + ...webpackConfig, + output: { + filename: "bundle.js", + path: outputPath, + publicPath: "http/s://test:malformed%5Med@test.example.com", + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + ); + }); + + afterAll(async () => { + await close(server, instance); + }); + + it('should return the "400" code for the "GET" request to the bundle file', async () => { + const response = await req.get("/bundle.js"); + + expect(response.statusCode).toBe(404); + }); + }); + describe('should respect empty "output.publicPath" and "output.path" options', () => { beforeAll(async () => { const compiler = getCompiler(webpackConfig); @@ -3003,6 +3095,74 @@ describe.each([ }); }); + describe("should handle known fs errors and response 404 code", () => { + let compiler; + + const outputPath = path.resolve( + __dirname, + "./outputs/basic-test-errors-404", + ); + + beforeAll(async () => { + compiler = getCompiler({ + ...webpackConfig, + output: { + filename: "bundle.js", + path: outputPath, + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + ); + + instance.context.outputFileSystem.mkdirSync(outputPath, { + recursive: true, + }); + instance.context.outputFileSystem.writeFileSync( + path.resolve(outputPath, "image.svg"), + "svg image", + ); + + instance.context.outputFileSystem.readFileSync = + function readFileSync() { + const error = new Error("test"); + + error.code = "ENAMETOOLONG"; + + throw error; + }; + instance.context.outputFileSystem.createReadStream = null; + }); + + afterAll(async () => { + await close(server, instance); + }); + + it('should return the "404" code for the "GET" request to the "image.svg" file', async () => { + const response = await req.get("/image.svg"); + + expect(response.statusCode).toBe(404); + expect(response.headers["content-type"]).toBe( + "text/html; charset=utf-8", + ); + expect(response.text).toEqual( + "\n" + + '\n' + + "\n" + + '\n' + + "Error\n" + + "\n" + + "\n" + + "
Not Found
\n" + + "\n" + + "", + ); + }); + }); + describe("should handle known fs errors and response 404 code #2", () => { let compiler; @@ -3228,74 +3388,6 @@ describe.each([ }); }); - describe("should handle known fs errors and response 404 code", () => { - let compiler; - - const outputPath = path.resolve( - __dirname, - "./outputs/basic-test-errors-404", - ); - - beforeAll(async () => { - compiler = getCompiler({ - ...webpackConfig, - output: { - filename: "bundle.js", - path: outputPath, - }, - }); - - [server, req, instance] = await frameworkFactory( - name, - framework, - compiler, - ); - - instance.context.outputFileSystem.mkdirSync(outputPath, { - recursive: true, - }); - instance.context.outputFileSystem.writeFileSync( - path.resolve(outputPath, "image.svg"), - "svg image", - ); - - instance.context.outputFileSystem.readFileSync = - function readFileSync() { - const error = new Error("test"); - - error.code = "ENAMETOOLONG"; - - throw error; - }; - instance.context.outputFileSystem.createReadStream = null; - }); - - afterAll(async () => { - await close(server, instance); - }); - - it('should return the "404" code for the "GET" request to the "image.svg" file', async () => { - const response = await req.get("/image.svg"); - - expect(response.statusCode).toBe(404); - expect(response.headers["content-type"]).toBe( - "text/html; charset=utf-8", - ); - expect(response.text).toEqual( - "\n" + - '\n' + - "\n" + - '\n' + - "Error\n" + - "\n" + - "\n" + - "
Not Found
\n" + - "\n" + - "", - ); - }); - }); - describe("should work when headers are already sent", () => { let compiler; diff --git a/types/index.d.ts b/types/index.d.ts index b30da6b87..233a3d8db 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -27,7 +27,7 @@ declare namespace wdm { Stats, MultiStats, ReadStream, - Extra, + FilenameWithExtra, EXPECTED_ANY, EXPECTED_FUNCTION, ExtendedServerResponse, @@ -127,7 +127,7 @@ type Configuration = import("webpack").Configuration; type Stats = import("webpack").Stats; type MultiStats = import("webpack").MultiStats; type ReadStream = import("fs").ReadStream; -type Extra = import("./middleware").Extra; +type FilenameWithExtra = import("./middleware").FilenameWithExtra; type EXPECTED_ANY = any; type EXPECTED_FUNCTION = Function; type ExtendedServerResponse = { @@ -325,12 +325,9 @@ type Middleware< res: ResponseInternal, next: NextFunction, ) => Promise; -type GetFilenameFromUrl = (url: string) => - | { - filename: string; - extra: Extra; - } - | undefined; +type GetFilenameFromUrl = ( + url: string, +) => Promise; type WaitUntilValid = (callback: Callback) => any; type Invalidate = (callback: Callback) => any; type Close = (callback: (err: Error | null | undefined) => void) => any; diff --git a/types/middleware.d.ts b/types/middleware.d.ts index 66c940756..b5f141b1d 100644 --- a/types/middleware.d.ts +++ b/types/middleware.d.ts @@ -27,16 +27,16 @@ declare namespace wrapper { ServerResponse, NormalizedHeaders, OutputFileSystem, + FSStats, Extra, }; } -/** @typedef {{ filename: string, extra: Extra }} FilenameWithExtra */ /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("./index.js").FilledContext} context context * @param {string} url url - * @returns {FilenameWithExtra | undefined} result of get filename from url + * @returns {Promise} result of get filename from url */ declare function getFilenameFromUrl< Request extends IncomingMessage, @@ -44,7 +44,7 @@ declare function getFilenameFromUrl< >( context: import("./index.js").FilledContext, url: string, -): FilenameWithExtra | undefined; +): Promise; /** * @template {IncomingMessage} Request * @template {ServerResponse} Response @@ -100,11 +100,12 @@ type IncomingMessage = import("./index.js").IncomingMessage; type ServerResponse = import("./index.js").ServerResponse; type NormalizedHeaders = import("./index.js").NormalizedHeaders; type OutputFileSystem = import("./index.js").OutputFileSystem; +type FSStats = import("fs").Stats; type Extra = { /** * stats */ - stats: import("fs").Stats; + stats: FSStats; /** * true when immutable, otherwise false */ From 3b615f272e1e8b1dd224e9a002e03c38878f9050 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 19 Mar 2026 16:30:14 +0300 Subject: [PATCH 4/6] refactor: use async --- src/middleware.js | 28 +++++++++++++++++++++++----- src/utils.js | 27 +++++++++++++++++++++------ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/middleware.js b/src/middleware.js index 38586c106..36b1f096d 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -5,7 +5,7 @@ const mime = require("mime-types"); const onFinishedStream = require("on-finished"); const { - createReadStreamOrReadFileSync, + createReadStreamOrReadFile, escapeHtml, etag, finish, @@ -210,7 +210,16 @@ async function getFilenameFromUrl(context, url) { let stats; try { - stats = outputFileSystem.statSync(filename); + stats = await new Promise((resolve, reject) => { + outputFileSystem.stat(filename, (err, res) => { + if (err) { + reject(err); + return; + } + + resolve(res); + }); + }); } catch (err) { if (isNotFoundError(err)) return; throw err; @@ -240,7 +249,16 @@ async function getFilenameFromUrl(context, url) { let stats; try { - stats = outputFileSystem.statSync(filename); + stats = await new Promise((resolve, reject) => { + outputFileSystem.stat(filename, (err, res) => { + if (err) { + reject(err); + return; + } + + resolve(res); + }); + }); } catch (err) { if (isNotFoundError(err)) return; throw err; @@ -925,7 +943,7 @@ function wrapper(context) { [start, end] = calcStartAndEnd(offset, len); try { - const result = createReadStreamOrReadFileSync( + const result = await createReadStreamOrReadFile( filename, extra.outputFileSystem, start, @@ -1070,7 +1088,7 @@ function wrapper(context) { [start, end] = calcStartAndEnd(offset, len); try { - ({ bufferOrStream, byteLength } = createReadStreamOrReadFileSync( + ({ bufferOrStream, byteLength } = await createReadStreamOrReadFile( filename, extra.outputFileSystem, start, diff --git a/src/utils.js b/src/utils.js index 73a6b6843..38ff41018 100644 --- a/src/utils.js +++ b/src/utils.js @@ -447,9 +447,9 @@ function finish(res, data) { * @param {OutputFileSystem} outputFileSystem output file system * @param {number} start start * @param {number} end end - * @returns {{ bufferOrStream: (Buffer | import("fs").ReadStream), byteLength: number }} result with buffer or stream and byte length + * @returns {Promise<{ bufferOrStream: (Buffer | import("fs").ReadStream), byteLength: number }>} result with buffer or stream and byte length */ -function createReadStreamOrReadFileSync( +async function createReadStreamOrReadFile( filename, outputFileSystem, start, @@ -472,11 +472,26 @@ function createReadStreamOrReadFileSync( end, }); - // Handle files with zero bytes byteLength = end === 0 ? 0 : end - start + 1; } else { - bufferOrStream = outputFileSystem.readFileSync(filename); - ({ byteLength } = bufferOrStream); + bufferOrStream = await new Promise( + /** + * @param {(value: Buffer) => void} resolve resolve + * @param {(reason: Error) => void} reject reject + */ + (resolve, reject) => { + outputFileSystem.readFile(filename, (err, data) => { + if (err) { + reject(err); + return; + } + + resolve(/** @type {Buffer} */ (data)); + }); + }, + ); + + byteLength = bufferOrStream.byteLength; } return { bufferOrStream, byteLength }; @@ -528,7 +543,7 @@ function setState(res, name, value) { } module.exports = { - createReadStreamOrReadFileSync, + createReadStreamOrReadFile, escapeHtml, etag, finish, From 727b587bed979c13a29fd6e8184f471c7d8f3054 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 19 Mar 2026 17:12:16 +0300 Subject: [PATCH 5/6] refactor: logic --- src/middleware.js | 47 ++++--------------------------- src/utils.js | 70 +++++++++++++++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 65 deletions(-) diff --git a/src/middleware.js b/src/middleware.js index 36b1f096d..e92ec449c 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -6,6 +6,7 @@ const onFinishedStream = require("on-finished"); const { createReadStreamOrReadFile, + destroyStream, escapeHtml, etag, finish, @@ -17,8 +18,10 @@ const { getResponseHeader, getResponseHeaders, getStatusCode, + getValueContentRangeHeader, initState, memorize, + parseHttpDate, parseTokenList, pipe, removeResponseHeader, @@ -309,48 +312,8 @@ async function getFilenameFromUrl(context, url) { } } -/** - * @param {"bytes"} type type - * @param {number} size size - * @param {import("range-parser").Range=} range range - * @returns {string} value of content range header - */ -function getValueContentRangeHeader(type, size, range) { - return `${type} ${range ? `${range.start}-${range.end}` : "*"}/${size}`; -} - -/** - * Parse an HTTP Date into a number. - * @param {string} date date - * @returns {number} timestamp - */ -function parseHttpDate(date) { - const timestamp = date && Date.parse(date); - - // istanbul ignore next: guard against date.js Date.parse patching - return typeof timestamp === "number" ? timestamp : Number.NaN; -} - const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/; -/** - * @param {import("fs").ReadStream} stream stream - * @param {boolean} suppress do need suppress? - * @returns {void} - */ -function destroyStream(stream, suppress) { - if (stream.destroyed) { - return; - } - - stream.destroy(); - - if (typeof stream.addListener === "function" && suppress) { - stream.removeAllListeners("error"); - stream.addListener("error", () => {}); - } -} - /** @type {Record} */ const statuses = { 400: "Bad Request", @@ -943,7 +906,7 @@ function wrapper(context) { [start, end] = calcStartAndEnd(offset, len); try { - const result = await createReadStreamOrReadFile( + const result = createReadStreamOrReadFile( filename, extra.outputFileSystem, start, @@ -1088,7 +1051,7 @@ function wrapper(context) { [start, end] = calcStartAndEnd(offset, len); try { - ({ bufferOrStream, byteLength } = await createReadStreamOrReadFile( + ({ bufferOrStream, byteLength } = createReadStreamOrReadFile( filename, extra.outputFileSystem, start, diff --git a/src/utils.js b/src/utils.js index 38ff41018..e09b8102b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -66,6 +66,28 @@ function escapeHtml(string) { /** @typedef {import("fs").Stats} Stats */ /** @typedef {import("fs").ReadStream} ReadStream */ +/** + * Parse an HTTP Date into a number. + * @param {string} date date + * @returns {number} timestamp + */ +function parseHttpDate(date) { + const timestamp = date && Date.parse(date); + + // istanbul ignore next: guard against date.js Date.parse patching + return typeof timestamp === "number" ? timestamp : Number.NaN; +} + +/** + * @param {"bytes"} type type + * @param {number} size size + * @param {import("range-parser").Range=} range range + * @returns {string} value of content range header + */ +function getValueContentRangeHeader(type, size, range) { + return `${type} ${range ? `${range.start}-${range.end}` : "*"}/${size}`; +} + /** * Generate a tag for a stat. * @param {Stats} stats stats @@ -447,14 +469,9 @@ function finish(res, data) { * @param {OutputFileSystem} outputFileSystem output file system * @param {number} start start * @param {number} end end - * @returns {Promise<{ bufferOrStream: (Buffer | import("fs").ReadStream), byteLength: number }>} result with buffer or stream and byte length + * @returns {{ bufferOrStream: (Buffer | import("fs").ReadStream), byteLength: number }} result with buffer or stream and byte length */ -async function createReadStreamOrReadFile( - filename, - outputFileSystem, - start, - end, -) { +function createReadStreamOrReadFile(filename, outputFileSystem, start, end) { /** @type {string | Buffer | import("fs").ReadStream} */ let bufferOrStream; /** @type {number} */ @@ -474,22 +491,8 @@ async function createReadStreamOrReadFile( byteLength = end === 0 ? 0 : end - start + 1; } else { - bufferOrStream = await new Promise( - /** - * @param {(value: Buffer) => void} resolve resolve - * @param {(reason: Error) => void} reject reject - */ - (resolve, reject) => { - outputFileSystem.readFile(filename, (err, data) => { - if (err) { - reject(err); - return; - } - - resolve(/** @type {Buffer} */ (data)); - }); - }, - ); + bufferOrStream = outputFileSystem.readFileSync(filename); + ({ byteLength } = bufferOrStream); byteLength = bufferOrStream.byteLength; } @@ -497,6 +500,24 @@ async function createReadStreamOrReadFile( return { bufferOrStream, byteLength }; } +/** + * @param {import("fs").ReadStream} stream stream + * @param {boolean} suppress do need suppress? + * @returns {void} + */ +function destroyStream(stream, suppress) { + if (stream.destroyed) { + return; + } + + stream.destroy(); + + if (typeof stream.addListener === "function" && suppress) { + stream.removeAllListeners("error"); + stream.addListener("error", () => {}); + } +} + /** * @template {ServerResponse & ExpectedServerResponse} Response * @param {Response} res res @@ -544,6 +565,7 @@ function setState(res, name, value) { module.exports = { createReadStreamOrReadFile, + destroyStream, escapeHtml, etag, finish, @@ -555,8 +577,10 @@ module.exports = { getResponseHeader, getResponseHeaders, getStatusCode, + getValueContentRangeHeader, initState, memorize, + parseHttpDate, parseTokenList, pipe, removeResponseHeader, From ee4f6ebc0912e70c77b0fe39fa0f92f5c9c75697 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 19 Mar 2026 17:16:31 +0300 Subject: [PATCH 6/6] chore: fix types --- types/utils.d.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/types/utils.d.ts b/types/utils.d.ts index e29ea9234..9041371b1 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -85,7 +85,7 @@ export type EXPECTED_ANY = import("./index").EXPECTED_ANY; * @param {number} end end * @returns {{ bufferOrStream: (Buffer | import("fs").ReadStream), byteLength: number }} result with buffer or stream and byte length */ -export function createReadStreamOrReadFileSync( +export function createReadStreamOrReadFile( filename: string, outputFileSystem: OutputFileSystem, start: number, @@ -94,6 +94,15 @@ export function createReadStreamOrReadFileSync( bufferOrStream: Buffer | import("fs").ReadStream; byteLength: number; }; +/** + * @param {import("fs").ReadStream} stream stream + * @param {boolean} suppress do need suppress? + * @returns {void} + */ +export function destroyStream( + stream: import("fs").ReadStream, + suppress: boolean, +): void; /** * @param {string} string raw HTML * @returns {string} escaped HTML @@ -204,6 +213,17 @@ export function getResponseHeaders< export function getStatusCode< Response extends ServerResponse & ExpectedServerResponse, >(res: Response): number; +/** + * @param {"bytes"} type type + * @param {number} size size + * @param {import("range-parser").Range=} range range + * @returns {string} value of content range header + */ +export function getValueContentRangeHeader( + type: "bytes", + size: number, + range?: import("range-parser").Range | undefined, +): string; /** * @template {ServerResponse & ExpectedServerResponse} Response * @param {Response} res res @@ -241,6 +261,14 @@ export function memorize( | undefined, callback?: ((value: T) => T) | undefined, ): FunctionReturning; +/** @typedef {import("fs").Stats} Stats */ +/** @typedef {import("fs").ReadStream} ReadStream */ +/** + * Parse an HTTP Date into a number. + * @param {string} date date + * @returns {number} timestamp + */ +export function parseHttpDate(date: string): number; /** * Parse a HTTP token list. * @param {string} str str