From 29335cb15a843f076a182e36f7b9f8f941308d3c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 28 Jan 2026 21:54:51 -0500 Subject: [PATCH 1/2] feat: add `forwardError` option to enable error forwarding to next middleware --- src/index.js | 1 + src/middleware.js | 5 ++--- src/options.json | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index de1403f83..7a8804ba6 100644 --- a/src/index.js +++ b/src/index.js @@ -125,6 +125,7 @@ const noop = () => {}; * @property {boolean=} lastModified options to generate last modified header * @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers * @property {boolean=} cacheImmutable is cache immutable + * @property {boolean=} forwardError forward error to next middleware */ /** diff --git a/src/middleware.js b/src/middleware.js index 078bbead7..d8410d6d2 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -161,8 +161,6 @@ function wrapper(context) { } const acceptedMethods = context.options.methods || ["GET", "HEAD"]; - // TODO do we need an option here? - const forwardError = false; initState(res); @@ -180,13 +178,14 @@ function wrapper(context) { * @returns {Promise} */ async function sendError(message, status, options) { - if (forwardError) { + if (context.options.forwardError) { const error = /** @type {Error & { statusCode: number }} */ (new Error(message)); error.statusCode = status; await goNext(error); + return; } const escapeHtml = getEscapeHtml(); diff --git a/src/options.json b/src/options.json index 0a55b69c9..1e83adaa1 100644 --- a/src/options.json +++ b/src/options.json @@ -172,6 +172,11 @@ "description": "Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash in file name like `image.a4c12bde.jpg`).", "link": "https://github.com/webpack/webpack-dev-middleware#cacheimmutable", "type": "boolean" + }, + "forwardError": { + "description": "Enable or disable forwarding errors to next middleware.", + "link": "https://github.com/webpack/webpack-dev-middleware#forwarderrors", + "type": "boolean" } }, "additionalProperties": false From a8134423a22cdd851ed1aa77797fe8987da19f25 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 30 Jan 2026 13:29:29 -0500 Subject: [PATCH 2/2] feat: add error middleware support in frameworkFactory for improved error handling --- test/middleware.test.js | 241 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/test/middleware.test.js b/test/middleware.test.js index 6ba82fcd3..d307067cf 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -123,6 +123,10 @@ async function frameworkFactory( } } + if (options.errorMiddleware) { + app.use(options.errorMiddleware); + } + const server = await startServer(name, app); const req = request(server); @@ -147,6 +151,10 @@ async function frameworkFactory( } } + if (options.errorMiddleware) { + app.use(options.errorMiddleware); + } + return [server, req, instance.devMiddleware]; } default: { @@ -172,6 +180,10 @@ async function frameworkFactory( } } + if (options.errorMiddleware) { + app.use(options.errorMiddleware); + } + if (isFastify) { await app.ready(); } @@ -3610,6 +3622,235 @@ describe.each([ }); }); + describe("should call the next middleware for finished or errored requests by default", () => { + let compiler; + + const outputPath = path.resolve( + __dirname, + "./outputs/basic-test-errors-headers-sent", + ); + + let nextWasCalled = false; + + beforeAll(async () => { + compiler = getCompiler({ + ...webpackConfig, + output: { + filename: "bundle.js", + path: outputPath, + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + etag: "weak", + lastModified: true, + forwardError: true, + }, + { + errorMiddleware: () => { + if (name === "hapi") { + // There's no such thing as "the next route handler" in hapi. One request is matched to one or no route handlers. + } else if (name === "koa") { + // Middleware de error para Koa: (err, ctx, next) + nextWasCalled = true; + } else if (name === "hono") { + // Middleware de error para Hono: (err, c, next) + nextWasCalled = true; + } else { + // Middleware de error para Express, Connect, Fastify, Router: (err, req, res, next) + nextWasCalled = true; + } + }, + }, + ); + + instance.context.outputFileSystem.mkdirSync(outputPath, { + recursive: true, + }); + instance.context.outputFileSystem.writeFileSync( + path.resolve(outputPath, "index.html"), + "HTML", + ); + instance.context.outputFileSystem.writeFileSync( + path.resolve(outputPath, "image.svg"), + "svg image", + ); + instance.context.outputFileSystem.writeFileSync( + path.resolve(outputPath, "file.text"), + "text", + ); + + const originalMethod = + instance.context.outputFileSystem.createReadStream; + + instance.context.outputFileSystem.createReadStream = + function createReadStream(...args) { + if (args[0].endsWith("image.svg")) { + const brokenStream = new this.ReadStream(...args); + + brokenStream._read = function _read() { + const error = new Error("test"); + error.code = "ENAMETOOLONG"; + this.emit("error", error); + this.end(); + this.destroy(); + }; + + return brokenStream; + } + + return originalMethod(...args); + }; + }); + + afterAll(async () => { + await close(server, instance); + }); + + it("should work with piping stream", async () => { + const response1 = await req.get("/file.text"); + + expect(response1.statusCode).toBe(200); + expect(nextWasCalled).toBe(false); + }); + + it("should not allow to get files above root", async () => { + await req.get("/public/..%2f../middleware.test.js"); + + // expect(response.statusCode).toBe(403); + // expect(response.headers["content-type"]).toBe( + // "text/html; charset=utf-8", + // ); + // expect(response.text).toBe(` + // + // + // + // Error + // + // + //
Forbidden
+ // + // `); + expect(nextWasCalled).toBe(true); + }); + + it('should return the "412" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => { + await req.get("/file.text"); + + // expect(response1.statusCode).toBe(200); + // expect(response1.headers.etag).toBeDefined(); + // expect(response1.headers.etag.startsWith("W/")).toBe(true); + + await req.get("/file.text").set("if-match", "test"); + + // expect(response2.statusCode).toBe(412); + expect(nextWasCalled).toBe(true); + }); + + it('should return the "416" code for the "GET" request with the invalid range header', async () => { + await req.get("/file.text").set("Range", "bytes=9999999-"); + + // expect(response.statusCode).toBe(416); + // expect(response.headers["content-type"]).toBe( + // "text/html; charset=utf-8", + // ); + // expect(response.text).toBe( + // ` + // + // + // + // Error + // + // + //
Range Not Satisfiable
+ // + // `, + // ); + expect(nextWasCalled).toBe(true); + }); + + it('should return the "404" code for the "GET" request to the "image.svg" file when it throws a reading error', async () => { + 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" + + // "", + // ); + expect(nextWasCalled).toBe(true); + }); + + it('should return the "200" code for the "HEAD" request to the bundle file', async () => { + const response = await req.head("/file.text"); + + expect(response.statusCode).toBe(200); + expect(response.text).toBeUndefined(); + expect(nextWasCalled).toBe(false); + }); + + it('should return the "304" code for the "GET" request to the bundle file with etag and "if-none-match"', async () => { + const response1 = await req.get("/file.text"); + + expect(response1.statusCode).toBe(200); + expect(response1.headers.etag).toBeDefined(); + expect(response1.headers.etag.startsWith("W/")).toBe(true); + + const response2 = await req + .get("/file.text") + .set("if-none-match", response1.headers.etag); + + expect(response2.statusCode).toBe(304); + expect(response2.headers.etag).toBeDefined(); + expect(response2.headers.etag.startsWith("W/")).toBe(true); + + const response3 = await req + .get("/file.text") + .set("if-none-match", response1.headers.etag); + + expect(response3.statusCode).toBe(304); + expect(response3.headers.etag).toBeDefined(); + expect(response3.headers.etag.startsWith("W/")).toBe(true); + expect(nextWasCalled).toBe(false); + }); + + it('should return the "304" code for the "GET" request to the bundle file with lastModified and "if-modified-since" header', async () => { + const response1 = await req.get("/file.text"); + + expect(response1.statusCode).toBe(200); + expect(response1.headers["last-modified"]).toBeDefined(); + + const response2 = await req + .get("/file.text") + .set("if-modified-since", response1.headers["last-modified"]); + + expect(response2.statusCode).toBe(304); + expect(response2.headers["last-modified"]).toBeDefined(); + + const response3 = await req + .get("/file.text") + .set("if-modified-since", response2.headers["last-modified"]); + + expect(response3.statusCode).toBe(304); + expect(response3.headers["last-modified"]).toBeDefined(); + expect(nextWasCalled).toBe(false); + }); + }); + describe("should fallthrough for not found files", () => { let compiler;