From 29335cb15a843f076a182e36f7b9f8f941308d3c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 28 Jan 2026 21:54:51 -0500 Subject: [PATCH 1/9] 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/9] 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; From ef885d0f712fcd00c3b7bc3072926d69ff7a14bb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 22 Feb 2026 03:08:16 +0000 Subject: [PATCH 3/9] feat: enhance error handling by removing response headers before forwarding errors --- src/middleware.js | 8 + test/middleware.test.js | 364 +++++++++++++++++++--------------------- types/index.d.ts | 5 + 3 files changed, 184 insertions(+), 193 deletions(-) diff --git a/src/middleware.js b/src/middleware.js index d8410d6d2..c04dc000b 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -179,6 +179,14 @@ function wrapper(context) { */ async function sendError(message, status, options) { if (context.options.forwardError) { + if (!getHeadersSent(res)) { + const headers = getResponseHeaders(res); + + for (let i = 0; i < headers.length; i++) { + removeResponseHeader(res, headers[i]); + } + } + const error = /** @type {Error & { statusCode: number }} */ (new Error(message)); diff --git a/test/middleware.test.js b/test/middleware.test.js index d307067cf..5c4b9709e 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -3622,234 +3622,212 @@ describe.each([ }); }); - describe("should call the next middleware for finished or errored requests by default", () => { - let compiler; + (name === "express" || name === "router" || name === "express-v4" + ? describe + : describe.skip)( + "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", - ); + const outputPath = path.resolve( + __dirname, + "./outputs/basic-test-errors-headers-sent", + ); - let nextWasCalled = false; + let nextWasCalled = false; - beforeAll(async () => { - compiler = getCompiler({ - ...webpackConfig, - output: { - filename: "bundle.js", - path: outputPath, - }, - }); + 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; - } + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + etag: "weak", + lastModified: true, + forwardError: true, }, - }, - ); + { + errorMiddleware: (_error, _req, res, _next) => { + 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; + res.statusCode = 500; + res.end("error"); + } + }, + }, + ); - 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", - ); + 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; + 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); + 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(); - }; + brokenStream._read = function _read() { + const error = new Error("test"); + error.code = "ENAMETOOLONG"; + this.emit("error", error); + this.end(); + this.destroy(); + }; - return brokenStream; - } + return brokenStream; + } - return originalMethod(...args); - }; - }); + return originalMethod(...args); + }; + }); - afterAll(async () => { - await close(server, instance); - }); + beforeEach(() => { + nextWasCalled = false; + }); - it("should work with piping stream", async () => { - const response1 = await req.get("/file.text"); + afterAll(async () => { + await close(server, instance); + }); - expect(response1.statusCode).toBe(200); - expect(nextWasCalled).toBe(false); - }); + it("should work with piping stream", async () => { + const response1 = await req.get("/file.text"); - 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); - }); + expect(response1.statusCode).toBe(200); + expect(nextWasCalled).toBe(false); + }); - 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"); + it("should not allow to get files above root", async () => { + const response = await req.get( + "/public/..%2f../middleware.test.js", + ); - // expect(response1.statusCode).toBe(200); - // expect(response1.headers.etag).toBeDefined(); - // expect(response1.headers.etag.startsWith("W/")).toBe(true); + expect(response.statusCode).toBe(500); + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + }); - await req.get("/file.text").set("if-match", "test"); + it('should return the "412" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => { + const response1 = await req.get("/file.text"); - // expect(response2.statusCode).toBe(412); - expect(nextWasCalled).toBe(true); - }); + expect(response1.statusCode).toBe(200); + expect(response1.headers.etag).toBeDefined(); + expect(response1.headers.etag.startsWith("W/")).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); - }); + const response2 = await req + .get("/file.text") + .set("if-match", "test"); - 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); - }); + expect(response2.statusCode).toBe(500); + expect(response2.text).toBe("error"); + 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"); + it('should return the "416" code for the "GET" request with the invalid range header', async () => { + const response = await req + .get("/file.text") + .set("Range", "bytes=9999999-"); - expect(response.statusCode).toBe(200); - expect(response.text).toBeUndefined(); - expect(nextWasCalled).toBe(false); - }); + expect(response.statusCode).toBe(500); + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + }); - 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"); + it('should return the "500" code for the "GET" request to the "image.svg" file when it throws a reading error', async () => { + const response = await req.get("/image.svg"); - expect(response1.statusCode).toBe(200); - expect(response1.headers.etag).toBeDefined(); - expect(response1.headers.etag.startsWith("W/")).toBe(true); + expect(response.statusCode).toBe(500); + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + }); - const response2 = await req - .get("/file.text") - .set("if-none-match", response1.headers.etag); + it('should return the "200" code for the "HEAD" request to the bundle file', async () => { + const response = await req.head("/file.text"); - expect(response2.statusCode).toBe(304); - expect(response2.headers.etag).toBeDefined(); - expect(response2.headers.etag.startsWith("W/")).toBe(true); + expect(response.statusCode).toBe(200); + expect(response.text).toBeUndefined(); + expect(nextWasCalled).toBe(false); + }); - const response3 = await req - .get("/file.text") - .set("if-none-match", response1.headers.etag); + 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(response3.statusCode).toBe(304); - expect(response3.headers.etag).toBeDefined(); - expect(response3.headers.etag.startsWith("W/")).toBe(true); - expect(nextWasCalled).toBe(false); - }); + expect(response1.statusCode).toBe(200); + expect(response1.headers.etag).toBeDefined(); + expect(response1.headers.etag.startsWith("W/")).toBe(true); - 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"); + const response2 = await req + .get("/file.text") + .set("if-none-match", response1.headers.etag); - expect(response1.statusCode).toBe(200); - expect(response1.headers["last-modified"]).toBeDefined(); + expect(response2.statusCode).toBe(304); + expect(response2.headers.etag).toBeDefined(); + expect(response2.headers.etag.startsWith("W/")).toBe(true); - const response2 = await req - .get("/file.text") - .set("if-modified-since", response1.headers["last-modified"]); + const response3 = await req + .get("/file.text") + .set("if-none-match", response1.headers.etag); - expect(response2.statusCode).toBe(304); - expect(response2.headers["last-modified"]).toBeDefined(); + expect(response3.statusCode).toBe(304); + expect(response3.headers.etag).toBeDefined(); + expect(response3.headers.etag.startsWith("W/")).toBe(true); + expect(nextWasCalled).toBe(false); + }); - const response3 = await req - .get("/file.text") - .set("if-modified-since", response2.headers["last-modified"]); + 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(response3.statusCode).toBe(304); - expect(response3.headers["last-modified"]).toBeDefined(); - expect(nextWasCalled).toBe(false); - }); - }); + 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; diff --git a/types/index.d.ts b/types/index.d.ts index 1ba81cd6c..e3fb18505 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -94,6 +94,7 @@ export = wdm; * @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 */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] @@ -449,6 +450,10 @@ type Options< * is cache immutable */ cacheImmutable?: boolean | undefined; + /** + * forward error to next middleware + */ + forwardError?: boolean | undefined; }; type Middleware< RequestInternal extends IncomingMessage = import("http").IncomingMessage, From 67e67c1e0953a78d123b234b98b2318c9e8503e1 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 22 Feb 2026 03:20:23 +0000 Subject: [PATCH 4/9] feat: remove error middleware option from frameworkFactory for cleaner middleware setup --- test/middleware.test.js | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/test/middleware.test.js b/test/middleware.test.js index 5c4b9709e..a18fc4d5d 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -123,10 +123,6 @@ async function frameworkFactory( } } - if (options.errorMiddleware) { - app.use(options.errorMiddleware); - } - const server = await startServer(name, app); const req = request(server); @@ -150,11 +146,6 @@ async function frameworkFactory( app.use(item); } } - - if (options.errorMiddleware) { - app.use(options.errorMiddleware); - } - return [server, req, instance.devMiddleware]; } default: { @@ -180,10 +171,6 @@ async function frameworkFactory( } } - if (options.errorMiddleware) { - app.use(options.errorMiddleware); - } - if (isFastify) { await app.ready(); } @@ -3655,21 +3642,14 @@ describe.each([ forwardError: true, }, { - errorMiddleware: (_error, _req, res, _next) => { - 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) + setupMiddlewares: (middlewares) => { + middlewares.push((_error, _req, res, _next) => { nextWasCalled = true; res.statusCode = 500; res.end("error"); - } + }); + + return middlewares; }, }, ); From afe1f8237d6913c0e3682788ff2dd7d1444d42b4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 22 Feb 2026 03:55:16 +0000 Subject: [PATCH 5/9] test: update middleware tests to reflect changes in error handling with forwardError option --- test/middleware.test.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/middleware.test.js b/test/middleware.test.js index a18fc4d5d..801868f84 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -3609,10 +3609,10 @@ describe.each([ }); }); - (name === "express" || name === "router" || name === "express-v4" - ? describe - : describe.skip)( - "should call the next middleware for finished or errored requests by default", + (name === "koa" || name === "hapi" || name === "hono" + ? describe.skip + : describe)( + "should call the next middleware for finished or errored requests when forwardError is enabled", () => { let compiler; @@ -3708,7 +3708,7 @@ describe.each([ expect(nextWasCalled).toBe(false); }); - it("should not allow to get files above root", async () => { + it('should return the "500" code for requests above root', async () => { const response = await req.get( "/public/..%2f../middleware.test.js", ); @@ -3718,7 +3718,7 @@ describe.each([ 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 () => { + it('should return the "500" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => { const response1 = await req.get("/file.text"); expect(response1.statusCode).toBe(200); @@ -3734,7 +3734,7 @@ describe.each([ expect(nextWasCalled).toBe(true); }); - it('should return the "416" code for the "GET" request with the invalid range header', async () => { + it('should return the "500" code for the "GET" request with the invalid range header', async () => { const response = await req .get("/file.text") .set("Range", "bytes=9999999-"); From 441a9cacb29024d77f465d4bce2a26e5d820e1cb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 22 Feb 2026 16:12:01 +0000 Subject: [PATCH 6/9] test: update middleware tests to handle error forwarding for hapi framework --- test/middleware.test.js | 50 ++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/test/middleware.test.js b/test/middleware.test.js index 801868f84..2c7a4d48a 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -3609,9 +3609,7 @@ describe.each([ }); }); - (name === "koa" || name === "hapi" || name === "hono" - ? describe.skip - : describe)( + (name === "koa" || name === "hono" ? describe.skip : describe)( "should call the next middleware for finished or errored requests when forwardError is enabled", () => { let compiler; @@ -3643,11 +3641,15 @@ describe.each([ }, { setupMiddlewares: (middlewares) => { - middlewares.push((_error, _req, res, _next) => { - nextWasCalled = true; - res.statusCode = 500; - res.end("error"); - }); + 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 { + middlewares.push((_error, _req, res, _next) => { + nextWasCalled = true; + res.statusCode = 500; + res.end("error"); + }); + } return middlewares; }, @@ -3714,8 +3716,12 @@ describe.each([ ); expect(response.statusCode).toBe(500); - expect(response.text).toBe("error"); - expect(nextWasCalled).toBe(true); + if (name !== "hapi") { + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + } else { + expect(nextWasCalled).toBe(false); + } }); it('should return the "500" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => { @@ -3730,8 +3736,12 @@ describe.each([ .set("if-match", "test"); expect(response2.statusCode).toBe(500); - expect(response2.text).toBe("error"); - expect(nextWasCalled).toBe(true); + if (name !== "hapi") { + expect(response2.text).toBe("error"); + expect(nextWasCalled).toBe(true); + } else { + expect(nextWasCalled).toBe(false); + } }); it('should return the "500" code for the "GET" request with the invalid range header', async () => { @@ -3740,16 +3750,24 @@ describe.each([ .set("Range", "bytes=9999999-"); expect(response.statusCode).toBe(500); - expect(response.text).toBe("error"); - expect(nextWasCalled).toBe(true); + if (name !== "hapi") { + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + } else { + expect(nextWasCalled).toBe(false); + } }); it('should return the "500" code for the "GET" request to the "image.svg" file when it throws a reading error', async () => { const response = await req.get("/image.svg"); expect(response.statusCode).toBe(500); - expect(response.text).toBe("error"); - expect(nextWasCalled).toBe(true); + if (name !== "hapi") { + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + } else { + expect(nextWasCalled).toBe(false); + } }); it('should return the "200" code for the "HEAD" request to the bundle file', async () => { From ba17cf0fc40223e87d7747d504ae2721a5a7fd76 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 22 Feb 2026 16:37:51 +0000 Subject: [PATCH 7/9] feat: implement error forwarding in koa middleware and update tests accordingly --- src/index.js | 8 ++++++++ src/middleware.js | 2 ++ test/middleware.test.js | 34 +++++++++++++++++++++++----------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/index.js b/src/index.js index 7a8804ba6..b2e22390d 100644 --- a/src/index.js +++ b/src/index.js @@ -442,6 +442,7 @@ function koaWrapper(compiler, options) { ctx.body = stream; isFinished = true; + resolve(); }; /** @@ -480,6 +481,13 @@ function koaWrapper(compiler, options) { }, ); } catch (err) { + if (options?.forwardError) { + await next(); + + // need the return for prevent to execute the code below and override the status and body set by user in the next middleware + return; + } + ctx.status = /** @type {Error & { statusCode: number }} */ (err).statusCode || /** @type {Error & { status: number }} */ (err).status || diff --git a/src/middleware.js b/src/middleware.js index c04dc000b..946d4d73f 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -193,6 +193,8 @@ function wrapper(context) { error.statusCode = status; await goNext(error); + + // need the return for prevent to execute the code below and override the status and body set by user in the next middleware return; } diff --git a/test/middleware.test.js b/test/middleware.test.js index 2c7a4d48a..46e6ee46b 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -3609,7 +3609,7 @@ describe.each([ }); }); - (name === "koa" || name === "hono" ? describe.skip : describe)( + (name === "hono" ? describe.skip : describe)( "should call the next middleware for finished or errored requests when forwardError is enabled", () => { let compiler; @@ -3643,6 +3643,12 @@ describe.each([ setupMiddlewares: (middlewares) => { 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") { + middlewares.push(async (ctx) => { + nextWasCalled = true; + ctx.status = 500; + ctx.body = "error"; + }); } else { middlewares.push((_error, _req, res, _next) => { nextWasCalled = true; @@ -3758,17 +3764,23 @@ describe.each([ } }); - it('should return the "500" code for the "GET" request to the "image.svg" file when it throws a reading error', async () => { - const response = await req.get("/image.svg"); + // TODO: why koa don't catch for their error handling when stream emit error? + (name === "koa" ? it.skip : it)( + 'should return the "500" code for the "GET" request to the "image.svg" file when it throws a reading error', + async () => { + const response = await req.get("/image.svg"); - expect(response.statusCode).toBe(500); - if (name !== "hapi") { - expect(response.text).toBe("error"); - expect(nextWasCalled).toBe(true); - } else { - expect(nextWasCalled).toBe(false); - } - }); + // eslint-disable-next-line jest/no-standalone-expect + expect(response.statusCode).toBe(500); + if (name !== "hapi") { + // eslint-disable-next-line jest/no-standalone-expect + expect(nextWasCalled).toBe(true); + } else { + // eslint-disable-next-line jest/no-standalone-expect + expect(nextWasCalled).toBe(false); + } + }, + ); it('should return the "200" code for the "HEAD" request to the bundle file', async () => { const response = await req.head("/file.text"); From 215a71c21cad962a26768b1cbab5bd3f75a31a3f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 22 Feb 2026 16:57:51 +0000 Subject: [PATCH 8/9] feat: add error forwarding support in hono middleware and update tests accordingly --- src/index.js | 6 + test/middleware.test.js | 367 ++++++++++++++++++++-------------------- 2 files changed, 189 insertions(+), 184 deletions(-) diff --git a/src/index.js b/src/index.js index b2e22390d..9e7cff79e 100644 --- a/src/index.js +++ b/src/index.js @@ -662,6 +662,12 @@ function honoWrapper(compiler, options) { }, ); } catch (err) { + if (options?.forwardError) { + await next(); + + // need the return for prevent to execute the code below and override the status and body set by user in the next middleware + return; + } context.status(500); return context.json({ message: /** @type {Error} */ (err).message }); diff --git a/test/middleware.test.js b/test/middleware.test.js index 46e6ee46b..0ad0a2740 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -3609,235 +3609,234 @@ describe.each([ }); }); - (name === "hono" ? describe.skip : describe)( - "should call the next middleware for finished or errored requests when forwardError is enabled", - () => { - let compiler; + describe("should call the next middleware for finished or errored requests when forwardError is enabled", () => { + let compiler; - const outputPath = path.resolve( - __dirname, - "./outputs/basic-test-errors-headers-sent", - ); + const outputPath = path.resolve( + __dirname, + "./outputs/basic-test-errors-headers-sent", + ); - let nextWasCalled = false; + let nextWasCalled = false; - beforeAll(async () => { - compiler = getCompiler({ - ...webpackConfig, - output: { - filename: "bundle.js", - path: outputPath, - }, - }); + 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, - }, - { - setupMiddlewares: (middlewares) => { - 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") { - middlewares.push(async (ctx) => { - nextWasCalled = true; - ctx.status = 500; - ctx.body = "error"; - }); - } else { - middlewares.push((_error, _req, res, _next) => { - nextWasCalled = true; - res.statusCode = 500; - res.end("error"); - }); - } + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + etag: "weak", + lastModified: true, + forwardError: true, + }, + { + setupMiddlewares: (middlewares) => { + 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") { + middlewares.push(async (ctx) => { + nextWasCalled = true; + ctx.status = 500; + ctx.body = "error"; + }); + } else if (name === "hono") { + middlewares.push(async (ctx) => { + nextWasCalled = true; + ctx.status(500); + return ctx.text("error"); + }); + } else { + middlewares.push((_error, _req, res, _next) => { + nextWasCalled = true; + res.statusCode = 500; + res.end("error"); + }); + } - return middlewares; - }, + return middlewares; }, - ); + }, + ); - 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", - ); + 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; + 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); + 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(); - }; + brokenStream._read = function _read() { + const error = new Error("test"); + error.code = "ENAMETOOLONG"; + this.emit("error", error); + this.end(); + this.destroy(); + }; - return brokenStream; - } + return brokenStream; + } - return originalMethod(...args); - }; - }); + return originalMethod(...args); + }; + }); - beforeEach(() => { - nextWasCalled = false; - }); + beforeEach(() => { + nextWasCalled = false; + }); - afterAll(async () => { - await close(server, instance); - }); + 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 work with piping stream", async () => { - const response1 = await req.get("/file.text"); + it('should return the "500" code for requests above root', async () => { + const response = await req.get("/public/..%2f../middleware.test.js"); - expect(response1.statusCode).toBe(200); + expect(response.statusCode).toBe(500); + if (name !== "hapi") { + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + } else { expect(nextWasCalled).toBe(false); - }); + } + }); - it('should return the "500" code for requests above root', async () => { - const response = await req.get( - "/public/..%2f../middleware.test.js", - ); + it('should return the "500" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => { + const response1 = await req.get("/file.text"); - expect(response.statusCode).toBe(500); - if (name !== "hapi") { - expect(response.text).toBe("error"); - expect(nextWasCalled).toBe(true); - } else { - expect(nextWasCalled).toBe(false); - } - }); + expect(response1.statusCode).toBe(200); + expect(response1.headers.etag).toBeDefined(); + expect(response1.headers.etag.startsWith("W/")).toBe(true); - it('should return the "500" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => { - const response1 = await req.get("/file.text"); + const response2 = await req.get("/file.text").set("if-match", "test"); - expect(response1.statusCode).toBe(200); - expect(response1.headers.etag).toBeDefined(); - expect(response1.headers.etag.startsWith("W/")).toBe(true); + expect(response2.statusCode).toBe(500); + if (name !== "hapi") { + expect(response2.text).toBe("error"); + expect(nextWasCalled).toBe(true); + } else { + expect(nextWasCalled).toBe(false); + } + }); - const response2 = await req - .get("/file.text") - .set("if-match", "test"); + it('should return the "500" code for the "GET" request with the invalid range header', async () => { + const response = await req + .get("/file.text") + .set("Range", "bytes=9999999-"); - expect(response2.statusCode).toBe(500); - if (name !== "hapi") { - expect(response2.text).toBe("error"); - expect(nextWasCalled).toBe(true); - } else { - expect(nextWasCalled).toBe(false); - } - }); + expect(response.statusCode).toBe(500); + if (name !== "hapi") { + expect(response.text).toBe("error"); + expect(nextWasCalled).toBe(true); + } else { + expect(nextWasCalled).toBe(false); + } + }); - it('should return the "500" code for the "GET" request with the invalid range header', async () => { - const response = await req - .get("/file.text") - .set("Range", "bytes=9999999-"); + // TODO: why koa and hono don't catch for their error handling when stream emit error? + (name === "koa" || name === "hono" ? it.skip : it)( + 'should return the "500" code for the "GET" request to the "image.svg" file when it throws a reading error', + async () => { + const response = await req.get("/image.svg"); + // eslint-disable-next-line jest/no-standalone-expect expect(response.statusCode).toBe(500); if (name !== "hapi") { - expect(response.text).toBe("error"); + // eslint-disable-next-line jest/no-standalone-expect expect(nextWasCalled).toBe(true); } else { + // eslint-disable-next-line jest/no-standalone-expect expect(nextWasCalled).toBe(false); } - }); - - // TODO: why koa don't catch for their error handling when stream emit error? - (name === "koa" ? it.skip : it)( - 'should return the "500" code for the "GET" request to the "image.svg" file when it throws a reading error', - async () => { - const response = await req.get("/image.svg"); - - // eslint-disable-next-line jest/no-standalone-expect - expect(response.statusCode).toBe(500); - if (name !== "hapi") { - // eslint-disable-next-line jest/no-standalone-expect - expect(nextWasCalled).toBe(true); - } else { - // eslint-disable-next-line jest/no-standalone-expect - expect(nextWasCalled).toBe(false); - } - }, - ); + }, + ); - it('should return the "200" code for the "HEAD" request to the bundle file', async () => { - const response = await req.head("/file.text"); + 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); - }); + 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"); + 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); + 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); + 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); + 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); + 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); - }); + 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"); + 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(); + 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"]); + 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(); + 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"]); + 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); - }); - }, - ); + expect(response3.statusCode).toBe(304); + expect(response3.headers["last-modified"]).toBeDefined(); + expect(nextWasCalled).toBe(false); + }); + }); describe("should fallthrough for not found files", () => { let compiler; From 829ba0d6f5901ca7c958234dda5697f44c5ce166 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 22 Feb 2026 17:45:53 +0000 Subject: [PATCH 9/9] feat: add forwardError option to README with usage example --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 113fdc2e5..46a54691c 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ See [below](#other-servers) for an example of use with fastify. | **[`writeToDisk`](#writetodisk)** | `boolean\|Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. | | **[`outputFileSystem`](#outputfilesystem)** | `Object` | [`memfs`](https://github.com/streamich/memfs) | Set the default file system which will be used by webpack as primary destination of generated files. | | **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. | +| **[`forwardError`](#forwarderror)** | `boolean` | `false` | Enable or disable forwarding errors to the next middleware. | The middleware accepts an `options` Object. The following is a property reference for the Object. @@ -476,6 +477,35 @@ instance.waitUntilValid(() => { }); ``` +### `forwardError` + +Type: `boolean` +Default: `false` + +Enable or disable forwarding errors to the next middleware. If `true`, errors will be forwarded to the next middleware, otherwise, they will be handled by `webpack-dev-middleware` and a response will be handled case by case. + +This option don't work with hono, koa and hapi, because of the differences in error handling between these frameworks and express. + +```js +const express = require("express"); +const webpack = require("webpack"); +const middleware = require("webpack-dev-middleware"); + +const compiler = webpack({ + /* Webpack configuration */ +}); + +const instance = middleware(compiler, { forwardError: true }); + +const app = express(); +app.use(instance); + +app.use((err, req, res, next) => { + console.log(`Error: ${err}`); + res.status(500).send("Something broke!"); +}); +``` + ## FAQ ### Avoid blocking requests to non-webpack resources.