Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ See [below](#other-servers) for examples of use with other servers.
| **[`writeToDisk`](#writetodisk)** | `boolean\|Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your Rspack configuration. |
| **[`outputFileSystem`](#outputfilesystem)** | `Object` | [`memfs`](https://github.com/streamich/memfs) | Set the default file system which will be used by Rspack 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.

Expand Down Expand Up @@ -313,6 +314,33 @@ devMiddleware(compiler, {
});
```

### forwardError

Type: `Boolean`
Default: `false`

When enabled, handled errors are forwarded to the next middleware instead of being rendered as the built-in HTML error page.
This allows Connect/Express/Router and the Koa/Hono wrappers to hand errors to your application's own error middleware.
Hapi still does not support this option because its request lifecycle does not expose equivalent `next(err)` forwarding semantics.

```js
const express = require("express");
const { devMiddleware } = require("@rspack/dev-middleware");
const { rspack } = require("@rspack/core");

const compiler = rspack({
/* Rspack configuration */
});

const app = express();

app.use(devMiddleware(compiler, { forwardError: true }));

app.use((error, req, res, next) => {
res.status(500).send("Something broke!");
});
```

## API

`@rspack/dev-middleware` also provides convenience methods that can be use to
Expand Down
71 changes: 65 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,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 enable immutable cache headers for immutable assets (defaults to true when omitted)
* @property {boolean=} forwardError forward errors to the next middleware
*/

/**
Expand Down Expand Up @@ -192,6 +193,42 @@ const noop = () => {};
* @typedef {T & { [P in K]: NonNullable<T[P]> }} WithoutUndefined
*/

/**
* @param {import("fs").ReadStream} stream readable stream
* @param {(error?: Error) => void} callback callback
* @returns {void}
*/
function waitUntilStreamReady(stream, callback) {
let isResolved = false;

/**
* @param {Error=} error error
* @returns {void}
*/
const onEvent = (error) => {
if (isResolved) {
return;
}

isResolved = true;

stream.removeListener("error", onEvent);
stream.removeListener("readable", onEvent);
stream.removeListener("end", onEvent);

if (error) {
stream.destroy();
}

callback(error);
};

stream.once("error", onEvent);
stream.once("readable", onEvent);
// Empty stream
stream.once("end", onEvent);
Comment on lines +226 to +229
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve stream wait on close events

waitUntilStreamReady only subscribes to error, readable, and end, so a stream that is destroyed before producing data can leave the wrapper promise pending forever. This is reachable in Koa/Hono when the client disconnects early: on-finished cleanup destroys the file stream, which emits close without guaranteeing readable/end, and the request middleware never resolves/rejects. Please also handle close (or an already-destroyed stream) so aborted stream responses do not hang request processing.

Useful? React with 👍 / 👎.

}

/**
* @template {IncomingMessage} [RequestInternal=IncomingMessage]
* @template {ServerResponse} [ResponseInternal=ServerResponse]
Expand Down Expand Up @@ -435,10 +472,17 @@ function koaWrapper(compiler, options) {
* @param {import("fs").ReadStream} stream readable stream
*/
res.stream = (stream) => {
ctx.body = stream;
waitUntilStreamReady(stream, (error) => {
if (error) {
reject(error);
return;
}

isFinished = true;
resolve();
ctx.body = stream;

isFinished = true;
resolve();
});
};
/**
* @param {string | Buffer} data data
Expand Down Expand Up @@ -476,6 +520,10 @@ function koaWrapper(compiler, options) {
},
);
} catch (err) {
if (options?.forwardError) {
throw err;
}

ctx.status =
/** @type {Error & { statusCode: number }} */ (err).statusCode ||
/** @type {Error & { status: number }} */ (err).status ||
Expand Down Expand Up @@ -602,10 +650,17 @@ function honoWrapper(compiler, options) {
* @param {import("fs").ReadStream} stream readable stream
*/
res.stream = (stream) => {
body = stream;
waitUntilStreamReady(stream, (error) => {
if (error) {
reject(error);
return;
}

isFinished = true;
resolve();
body = stream;

isFinished = true;
resolve();
});
};

/**
Expand Down Expand Up @@ -651,6 +706,10 @@ function honoWrapper(compiler, options) {
},
);
} catch (err) {
if (options?.forwardError) {
throw err;
}

context.status(500);

return context.json({ message: /** @type {Error} */ (err).message });
Expand Down
14 changes: 11 additions & 3 deletions src/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,6 @@ function wrapper(context) {
}

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

initState(res);

Expand All @@ -212,13 +210,23 @@ function wrapper(context) {
* @returns {Promise<void>}
*/
async function sendError(message, status, options) {
if (forwardError) {
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));
error.statusCode = status;

await goNext(error);

return;
}

const escapeHtml = getEscapeHtml();
Expand Down
Loading