Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions .changeset/multipart-range-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-dev-middleware": minor
---

Answer a `Range` header with more than one range with `206 Partial Content` and a `multipart/byteranges` body, instead of the whole file. Each part carries its own `Content-Type` and `Content-Range`, ranges that overlap or touch are merged first, and each range is streamed from the output file system, so asking for a few slices of a large asset reads only those. A request for more than 200 ranges gets the whole file, as before.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"language": "en,en-gb",
"words": [
"apos",
"byteranges",
"cachable",
"cexoso",
"commitlint",
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,8 @@ const configuration = {/* Webpack configuration */};
const compiler = webpack(configuration);

middleware(compiler, {
// Note - if you send the `Range` header you will have `ReadStream`
// Note - if you send the `Range` header you will have `ReadStream`,
// and for more than one range a stream of the `multipart/byteranges` body
// Also `data` can be `string` or `Buffer`
modifyResponseData: (req, res, data, byteLength) =>
// Your logic
Expand Down
74 changes: 69 additions & 5 deletions src/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const querystring = require("node:querystring");
const { finished } = require("node:stream");

const {
createMultipartByteranges,
createReadStreamOrReadFile,
destroyStream,
escapeHtml,
Expand Down Expand Up @@ -362,6 +363,11 @@ const parseRangeHeaders = memorize(

const MAX_MAX_AGE = 31536000000;

// The most ranges a `multipart/byteranges` answer is built for, as Apache's
// `MaxRanges` default: each one costs a read and a part header, and a request
// for more is answered with the whole file instead.
const MAX_RANGES = 200;

/**
* @template {IncomingMessage} Request
* @template {ServerResponse} Response
Expand Down Expand Up @@ -474,6 +480,15 @@ function wrapper(context) {
return;
}

// Too late for an error page: the status and part of the body are out
// already — a `multipart/byteranges` body whose later range fails to
// read, say. Cut the response off, so the client sees it incomplete
// rather than waiting on it.
if (getHeadersSent(res)) {
getOutgoing(res).destroy();
return;
}

const content = statuses[status] || String(status);
let document = Buffer.from(
`<!DOCTYPE html>
Expand Down Expand Up @@ -917,7 +932,7 @@ function wrapper(context) {
/** @type {number} */
let end;

/** @type {undefined | Buffer | ReadStream} */
/** @type {undefined | Buffer | ReadStream | import("stream").Readable} */
let bufferOrStream;
/** @type {number | undefined} */
let byteLength;
Expand Down Expand Up @@ -1059,10 +1074,49 @@ function wrapper(context) {
context.logger.error(
"A malformed 'Range' header was provided. A regular response will be sent for this request.",
);
} else if (parsedRanges.length > MAX_RANGES) {
context.logger.warn(
`A 'Range' header with more than ${MAX_RANGES} ranges was provided. A regular response will be sent for this request.`,
);
} else if (parsedRanges.length > 1) {
context.logger.error(
"A 'Range' header with multiple ranges was provided. Multiple ranges are not supported, so a regular response will be sent for this request.",
// Still more than one once overlapping and adjacent ranges are
// merged: the parts of a `multipart/byteranges` body.
setStatusCode(res, 206);
removeResponseHeader(res, "Content-Range");

// Whole when a strong `ETag` read it already (it buffers what it
// reads), so each part can be cut from it rather than read again.
const whole = Buffer.isBuffer(bufferOrStream)
? bufferOrStream
: undefined;

let boundary;

try {
({ bufferOrStream, byteLength, boundary } =
createMultipartByteranges(
filename,
extra.outputFileSystem,
/** @type {import("range-parser").Ranges} */ (parsedRanges),
size,
/** @type {string | undefined} */
(getResponseHeader(res, "Content-Type")),
whole,
));
} catch (error) {
await errorHandler(/** @type {NodeJS.ErrnoException} */ (error));
return;
}

// Removed first: on some frameworks setting a header adds a value.
removeResponseHeader(res, "Content-Type");
setResponseHeader(
res,
"Content-Type",
`multipart/byteranges; boundary=${boundary}`,
);

isPartialContent = true;
}

if (parsedRanges !== -2 && parsedRanges.length === 1) {
Expand Down Expand Up @@ -1106,7 +1160,9 @@ function wrapper(context) {
context.options.modifyResponseData(
req,
res,
bufferOrStream,
// A multipart body is a plain `Readable`: the same stream to read
// or pipe, without a `ReadStream`'s file fields.
/** @type {Buffer | ReadStream} */ (bufferOrStream),
/** @type {number} */
(byteLength),
));
Expand Down Expand Up @@ -1151,7 +1207,15 @@ function wrapper(context) {
/** @type {import("fs").ReadStream} */ (bufferOrStream),
true,
);
errorHandler(error);

// A stream can fail part way — a `multipart/byteranges` body whose
// later range cannot be read — when there is no error page left to
// send. `sendError` cuts such a response off; where a framework does
// not report the headers as sent, sending the page throws, and the
// response is cut off here instead of the error going unhandled.
errorHandler(error).catch(() => {
getOutgoing(res).destroy();
});
});

pipe(res, /** @type {ReadStream} */ (bufferOrStream));
Expand Down
77 changes: 77 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,82 @@ function createReadStreamOrReadFile(filename, outputFileSystem, start, end) {
return { bufferOrStream, byteLength };
}

/**
* The body of a `206` answer to a request for more than one range: a
* `multipart/byteranges` message, each part the bytes of one range under its
* own `Content-Type` and `Content-Range` (RFC 9110, section 14.6).
*
* Streamed one range after another when the file system can stream, so a
* request for a few slices of a large asset reads only those; a buffer when it
* cannot, or when the whole file was already read for a strong `ETag`.
* @param {string} filename filename
* @param {OutputFileSystem} outputFileSystem output file system
* @param {{ start: number, end: number }[]} ranges the ranges, in the order asked for
* @param {number} size the size of the whole file
* @param {string | undefined} contentType the file's own `Content-Type`
* @param {Buffer=} buffer the whole file, when it was already read
* @returns {{ bufferOrStream: (Buffer | import("stream").Readable), byteLength: number, boundary: string }} the body, its length and the boundary between its parts
*/
function createMultipartByteranges(
filename,
outputFileSystem,
ranges,
size,
contentType,
buffer,
) {
const boundary = crypto.randomBytes(12).toString("hex");
const heads = ranges.map((range, index) =>
Buffer.from(
`${index === 0 ? "" : "\r\n"}--${boundary}\r\n${
contentType ? `Content-Type: ${contentType}\r\n` : ""
}Content-Range: ${getValueContentRangeHeader("bytes", size, range)}\r\n\r\n`,
),
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
let { byteLength } = tail;

for (const [index, range] of ranges.entries()) {
byteLength += heads[index].byteLength + (range.end - range.start + 1);
}

const canStream = typeof outputFileSystem.createReadStream === "function";

if (buffer || !canStream) {
const whole = buffer || outputFileSystem.readFileSync(filename);
/** @type {Buffer[]} */
const chunks = [];

for (const [index, range] of ranges.entries()) {
chunks.push(heads[index], whole.subarray(range.start, range.end + 1));
}

chunks.push(tail);

return { bufferOrStream: Buffer.concat(chunks), byteLength, boundary };
}

const { Readable } = require("node:stream");

/**
* @returns {AsyncGenerator<Buffer>} the parts, one range at a time
*/
async function* parts() {
for (const [index, range] of ranges.entries()) {
yield heads[index];

// Opened as it is reached, so only one is open at a time.
yield* /** @type {import("fs").createReadStream} */ (
outputFileSystem.createReadStream
)(filename, { start: range.start, end: range.end });
}

yield tail;
}

return { bufferOrStream: Readable.from(parts()), byteLength, boundary };
Comment thread
alexander-akait marked this conversation as resolved.
}

/**
* @param {import("fs").ReadStream} stream stream
* @param {boolean} suppress do need suppress?
Expand Down Expand Up @@ -1768,6 +1844,7 @@ module.exports = {
applyCors,
clientQuery,
createMimeTypes,
createMultipartByteranges,
createReadStreamOrReadFile,
destroyStream,
escapeHtml,
Expand Down
Loading
Loading