Skip to content

Commit 1fccf27

Browse files
bjohansebasalexander-akait
authored andcommitted
feat: add forwardError option to enable error forwarding to next middleware (#2259)
* feat: add `forwardError` option to enable error forwarding to next middleware * feat: add error middleware support in frameworkFactory for improved error handling * feat: enhance error handling by removing response headers before forwarding errors * feat: remove error middleware option from frameworkFactory for cleaner middleware setup * test: update middleware tests to reflect changes in error handling with forwardError option * test: update middleware tests to handle error forwarding for hapi framework * feat: implement error forwarding in koa middleware and update tests accordingly * feat: add error forwarding support in hono middleware and update tests accordingly * feat: add forwardError option to README with usage example
1 parent 055687c commit 1fccf27

6 files changed

Lines changed: 296 additions & 4 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ See [below](#other-servers) for an example of use with fastify.
7979
| **[`writeToDisk`](#writetodisk)** | `boolean\|Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. |
8080
| **[`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. |
8181
| **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. |
82+
| **[`forwardError`](#forwarderror)** | `boolean` | `false` | Enable or disable forwarding errors to the next middleware. |
8283

8384
The middleware accepts an `options` Object. The following is a property reference for the Object.
8485

@@ -476,6 +477,35 @@ instance.waitUntilValid(() => {
476477
});
477478
```
478479

480+
### `forwardError`
481+
482+
Type: `boolean`
483+
Default: `false`
484+
485+
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.
486+
487+
This option don't work with hono, koa and hapi, because of the differences in error handling between these frameworks and express.
488+
489+
```js
490+
const express = require("express");
491+
const webpack = require("webpack");
492+
const middleware = require("webpack-dev-middleware");
493+
494+
const compiler = webpack({
495+
/* Webpack configuration */
496+
});
497+
498+
const instance = middleware(compiler, { forwardError: true });
499+
500+
const app = express();
501+
app.use(instance);
502+
503+
app.use((err, req, res, next) => {
504+
console.log(`Error: ${err}`);
505+
res.status(500).send("Something broke!");
506+
});
507+
```
508+
479509
## FAQ
480510

481511
### Avoid blocking requests to non-webpack resources.

src/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ const noop = () => {};
125125
* @property {boolean=} lastModified options to generate last modified header
126126
* @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers
127127
* @property {boolean=} cacheImmutable is cache immutable
128+
* @property {boolean=} forwardError forward error to next middleware
128129
*/
129130

130131
/**
@@ -441,6 +442,7 @@ function koaWrapper(compiler, options) {
441442
ctx.body = stream;
442443

443444
isFinished = true;
445+
444446
resolve();
445447
};
446448
/**
@@ -479,6 +481,13 @@ function koaWrapper(compiler, options) {
479481
},
480482
);
481483
} catch (err) {
484+
if (options?.forwardError) {
485+
await next();
486+
487+
// need the return for prevent to execute the code below and override the status and body set by user in the next middleware
488+
return;
489+
}
490+
482491
ctx.status =
483492
/** @type {Error & { statusCode: number }} */ (err).statusCode ||
484493
/** @type {Error & { status: number }} */ (err).status ||
@@ -653,6 +662,12 @@ function honoWrapper(compiler, options) {
653662
},
654663
);
655664
} catch (err) {
665+
if (options?.forwardError) {
666+
await next();
667+
668+
// need the return for prevent to execute the code below and override the status and body set by user in the next middleware
669+
return;
670+
}
656671
context.status(500);
657672

658673
return context.json({ message: /** @type {Error} */ (err).message });

src/middleware.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,6 @@ function wrapper(context) {
161161
}
162162

163163
const acceptedMethods = context.options.methods || ["GET", "HEAD"];
164-
// TODO do we need an option here?
165-
const forwardError = false;
166164

167165
initState(res);
168166

@@ -180,13 +178,24 @@ function wrapper(context) {
180178
* @returns {Promise<void>}
181179
*/
182180
async function sendError(message, status, options) {
183-
if (forwardError) {
181+
if (context.options.forwardError) {
182+
if (!getHeadersSent(res)) {
183+
const headers = getResponseHeaders(res);
184+
185+
for (let i = 0; i < headers.length; i++) {
186+
removeResponseHeader(res, headers[i]);
187+
}
188+
}
189+
184190
const error =
185191
/** @type {Error & { statusCode: number }} */
186192
(new Error(message));
187193
error.statusCode = status;
188194

189195
await goNext(error);
196+
197+
// need the return for prevent to execute the code below and override the status and body set by user in the next middleware
198+
return;
190199
}
191200

192201
const escapeHtml = getEscapeHtml();

src/options.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@
172172
"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`).",
173173
"link": "https://github.com/webpack/webpack-dev-middleware#cacheimmutable",
174174
"type": "boolean"
175+
},
176+
"forwardError": {
177+
"description": "Enable or disable forwarding errors to next middleware.",
178+
"link": "https://github.com/webpack/webpack-dev-middleware#forwarderrors",
179+
"type": "boolean"
175180
}
176181
},
177182
"additionalProperties": false

test/middleware.test.js

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ async function frameworkFactory(
146146
app.use(item);
147147
}
148148
}
149-
150149
return [server, req, instance.devMiddleware];
151150
}
152151
default: {
@@ -3610,6 +3609,235 @@ describe.each([
36103609
});
36113610
});
36123611

3612+
describe("should call the next middleware for finished or errored requests when forwardError is enabled", () => {
3613+
let compiler;
3614+
3615+
const outputPath = path.resolve(
3616+
__dirname,
3617+
"./outputs/basic-test-errors-headers-sent",
3618+
);
3619+
3620+
let nextWasCalled = false;
3621+
3622+
beforeAll(async () => {
3623+
compiler = getCompiler({
3624+
...webpackConfig,
3625+
output: {
3626+
filename: "bundle.js",
3627+
path: outputPath,
3628+
},
3629+
});
3630+
3631+
[server, req, instance] = await frameworkFactory(
3632+
name,
3633+
framework,
3634+
compiler,
3635+
{
3636+
etag: "weak",
3637+
lastModified: true,
3638+
forwardError: true,
3639+
},
3640+
{
3641+
setupMiddlewares: (middlewares) => {
3642+
if (name === "hapi") {
3643+
// There's no such thing as "the next route handler" in hapi. One request is matched to one or no route handlers.
3644+
} else if (name === "koa") {
3645+
middlewares.push(async (ctx) => {
3646+
nextWasCalled = true;
3647+
ctx.status = 500;
3648+
ctx.body = "error";
3649+
});
3650+
} else if (name === "hono") {
3651+
middlewares.push(async (ctx) => {
3652+
nextWasCalled = true;
3653+
ctx.status(500);
3654+
return ctx.text("error");
3655+
});
3656+
} else {
3657+
middlewares.push((_error, _req, res, _next) => {
3658+
nextWasCalled = true;
3659+
res.statusCode = 500;
3660+
res.end("error");
3661+
});
3662+
}
3663+
3664+
return middlewares;
3665+
},
3666+
},
3667+
);
3668+
3669+
instance.context.outputFileSystem.mkdirSync(outputPath, {
3670+
recursive: true,
3671+
});
3672+
instance.context.outputFileSystem.writeFileSync(
3673+
path.resolve(outputPath, "index.html"),
3674+
"HTML",
3675+
);
3676+
instance.context.outputFileSystem.writeFileSync(
3677+
path.resolve(outputPath, "image.svg"),
3678+
"svg image",
3679+
);
3680+
instance.context.outputFileSystem.writeFileSync(
3681+
path.resolve(outputPath, "file.text"),
3682+
"text",
3683+
);
3684+
3685+
const originalMethod =
3686+
instance.context.outputFileSystem.createReadStream;
3687+
3688+
instance.context.outputFileSystem.createReadStream =
3689+
function createReadStream(...args) {
3690+
if (args[0].endsWith("image.svg")) {
3691+
const brokenStream = new this.ReadStream(...args);
3692+
3693+
brokenStream._read = function _read() {
3694+
const error = new Error("test");
3695+
error.code = "ENAMETOOLONG";
3696+
this.emit("error", error);
3697+
this.end();
3698+
this.destroy();
3699+
};
3700+
3701+
return brokenStream;
3702+
}
3703+
3704+
return originalMethod(...args);
3705+
};
3706+
});
3707+
3708+
beforeEach(() => {
3709+
nextWasCalled = false;
3710+
});
3711+
3712+
afterAll(async () => {
3713+
await close(server, instance);
3714+
});
3715+
3716+
it("should work with piping stream", async () => {
3717+
const response1 = await req.get("/file.text");
3718+
3719+
expect(response1.statusCode).toBe(200);
3720+
expect(nextWasCalled).toBe(false);
3721+
});
3722+
3723+
it('should return the "500" code for requests above root', async () => {
3724+
const response = await req.get("/public/..%2f../middleware.test.js");
3725+
3726+
expect(response.statusCode).toBe(500);
3727+
if (name !== "hapi") {
3728+
expect(response.text).toBe("error");
3729+
expect(nextWasCalled).toBe(true);
3730+
} else {
3731+
expect(nextWasCalled).toBe(false);
3732+
}
3733+
});
3734+
3735+
it('should return the "500" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => {
3736+
const response1 = await req.get("/file.text");
3737+
3738+
expect(response1.statusCode).toBe(200);
3739+
expect(response1.headers.etag).toBeDefined();
3740+
expect(response1.headers.etag.startsWith("W/")).toBe(true);
3741+
3742+
const response2 = await req.get("/file.text").set("if-match", "test");
3743+
3744+
expect(response2.statusCode).toBe(500);
3745+
if (name !== "hapi") {
3746+
expect(response2.text).toBe("error");
3747+
expect(nextWasCalled).toBe(true);
3748+
} else {
3749+
expect(nextWasCalled).toBe(false);
3750+
}
3751+
});
3752+
3753+
it('should return the "500" code for the "GET" request with the invalid range header', async () => {
3754+
const response = await req
3755+
.get("/file.text")
3756+
.set("Range", "bytes=9999999-");
3757+
3758+
expect(response.statusCode).toBe(500);
3759+
if (name !== "hapi") {
3760+
expect(response.text).toBe("error");
3761+
expect(nextWasCalled).toBe(true);
3762+
} else {
3763+
expect(nextWasCalled).toBe(false);
3764+
}
3765+
});
3766+
3767+
// TODO: why koa and hono don't catch for their error handling when stream emit error?
3768+
(name === "koa" || name === "hono" ? it.skip : it)(
3769+
'should return the "500" code for the "GET" request to the "image.svg" file when it throws a reading error',
3770+
async () => {
3771+
const response = await req.get("/image.svg");
3772+
3773+
// eslint-disable-next-line jest/no-standalone-expect
3774+
expect(response.statusCode).toBe(500);
3775+
if (name !== "hapi") {
3776+
// eslint-disable-next-line jest/no-standalone-expect
3777+
expect(nextWasCalled).toBe(true);
3778+
} else {
3779+
// eslint-disable-next-line jest/no-standalone-expect
3780+
expect(nextWasCalled).toBe(false);
3781+
}
3782+
},
3783+
);
3784+
3785+
it('should return the "200" code for the "HEAD" request to the bundle file', async () => {
3786+
const response = await req.head("/file.text");
3787+
3788+
expect(response.statusCode).toBe(200);
3789+
expect(response.text).toBeUndefined();
3790+
expect(nextWasCalled).toBe(false);
3791+
});
3792+
3793+
it('should return the "304" code for the "GET" request to the bundle file with etag and "if-none-match"', async () => {
3794+
const response1 = await req.get("/file.text");
3795+
3796+
expect(response1.statusCode).toBe(200);
3797+
expect(response1.headers.etag).toBeDefined();
3798+
expect(response1.headers.etag.startsWith("W/")).toBe(true);
3799+
3800+
const response2 = await req
3801+
.get("/file.text")
3802+
.set("if-none-match", response1.headers.etag);
3803+
3804+
expect(response2.statusCode).toBe(304);
3805+
expect(response2.headers.etag).toBeDefined();
3806+
expect(response2.headers.etag.startsWith("W/")).toBe(true);
3807+
3808+
const response3 = await req
3809+
.get("/file.text")
3810+
.set("if-none-match", response1.headers.etag);
3811+
3812+
expect(response3.statusCode).toBe(304);
3813+
expect(response3.headers.etag).toBeDefined();
3814+
expect(response3.headers.etag.startsWith("W/")).toBe(true);
3815+
expect(nextWasCalled).toBe(false);
3816+
});
3817+
3818+
it('should return the "304" code for the "GET" request to the bundle file with lastModified and "if-modified-since" header', async () => {
3819+
const response1 = await req.get("/file.text");
3820+
3821+
expect(response1.statusCode).toBe(200);
3822+
expect(response1.headers["last-modified"]).toBeDefined();
3823+
3824+
const response2 = await req
3825+
.get("/file.text")
3826+
.set("if-modified-since", response1.headers["last-modified"]);
3827+
3828+
expect(response2.statusCode).toBe(304);
3829+
expect(response2.headers["last-modified"]).toBeDefined();
3830+
3831+
const response3 = await req
3832+
.get("/file.text")
3833+
.set("if-modified-since", response2.headers["last-modified"]);
3834+
3835+
expect(response3.statusCode).toBe(304);
3836+
expect(response3.headers["last-modified"]).toBeDefined();
3837+
expect(nextWasCalled).toBe(false);
3838+
});
3839+
});
3840+
36133841
describe("should fallthrough for not found files", () => {
36143842
let compiler;
36153843

types/index.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export = wdm;
9494
* @property {boolean=} lastModified options to generate last modified header
9595
* @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers
9696
* @property {boolean=} cacheImmutable is cache immutable
97+
* @property {boolean=} forwardError forward error to next middleware
9798
*/
9899
/**
99100
* @template {IncomingMessage} [RequestInternal=IncomingMessage]
@@ -449,6 +450,10 @@ type Options<
449450
* is cache immutable
450451
*/
451452
cacheImmutable?: boolean | undefined;
453+
/**
454+
* forward error to next middleware
455+
*/
456+
forwardError?: boolean | undefined;
452457
};
453458
type Middleware<
454459
RequestInternal extends IncomingMessage = import("http").IncomingMessage,

0 commit comments

Comments
 (0)