Skip to content
Open
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
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
---

Added support for `multipart/byteranges` responses. A `Range` header with several separate ranges now returns a `206` with one part per range instead of the whole file. Requests with more than 200 ranges, or ranges that cover the whole file, still get a normal `200`.
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
47 changes: 43 additions & 4 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 {
createMultipartBody,
createReadStreamOrReadFile,
destroyStream,
escapeHtml,
Expand Down Expand Up @@ -337,6 +338,10 @@ async function getFilenameFromUrl(context, url) {

const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/;

// More ranges than this, and the response stays a regular `200`: no cache or
// client can use that many parts, and the boundary bookkeeping outweighs them.
const MAX_MULTIPART_RANGES = 200;

/** @type {Record<number, string>} */
const statuses = {
400: "Bad Request",
Expand Down Expand Up @@ -917,7 +922,7 @@ function wrapper(context) {
/** @type {number} */
let end;

/** @type {undefined | Buffer | ReadStream} */
/** @type {undefined | Buffer | ReadStream | import("node:stream").Readable} */
let bufferOrStream;
/** @type {number | undefined} */
let byteLength;
Expand Down Expand Up @@ -1060,9 +1065,43 @@ function wrapper(context) {
"A malformed 'Range' header 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.",
const ranges = /** @type {import("range-parser").Ranges} */ (
parsedRanges
);

let shouldUseMultipart = true;

if (ranges.length > MAX_MULTIPART_RANGES) {
shouldUseMultipart = false;
} else {
let covered = 0;
for (const range of ranges) {
covered += range.end - range.start + 1;
}
if (covered >= size) {
shouldUseMultipart = false;
}
}

if (shouldUseMultipart) {
const contentType = context.mimeTypes.contentType(filename);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use the selected response Content-Type for each part.

If headers overrides Content-Type, or mime.default supplies it for an unknown extension, this lookup does not return the Content-Type selected earlier in processRequest. The multipart parts can therefore identify the file with a different type or omit its type. Read the selected response header before setting the outer multipart Content-Type, and pass that value to createMultipartBody. RFC 9110 says each part should carry the Content-Type that the corresponding 200 response would have carried. (rfc-editor.org)

const multipartResult = createMultipartBody({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle synchronous multipart read failures.

If a filesystem has no createReadStream and the file disappears after stat, createMultipartBody throws from readFileSync. This call is outside the try that handles regular file-read failures. processRequest then rejects without sending the normal error response. Catch this call and pass the error to errorHandler, as the regular read path does.

filename,
outputFileSystem: extra.outputFileSystem,
ranges,
size,
contentType,
});
setStatusCode(res, 206);
setResponseHeader(
res,
"Content-Type",
`multipart/byteranges; boundary=${multipartResult.boundary}`,
);
isPartialContent = true;
bufferOrStream = multipartResult.bufferOrStream;
byteLength = multipartResult.byteLength;
}
}

if (parsedRanges !== -2 && parsedRanges.length === 1) {
Expand Down Expand Up @@ -1106,7 +1145,7 @@ function wrapper(context) {
context.options.modifyResponseData(
req,
res,
bufferOrStream,
/** @type {Buffer | ReadStream} */ (bufferOrStream),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Inspect the callback declaration and nearby usage without executing repository code.
rg -n -C 5 'ModifyResponseData|modifyResponseData|type ResponseData' types src README.md

Repository: webpack/webpack-dev-middleware

Length of output: 41759


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- diff ---'
git diff --no-ext-diff --unified=30 8a7af5e362f56610ace8d7dd0a4d277b4952c2a7 49fc520b8a20f25187096d5b0fdf45a8fe1119c4
printf '%s\n' '--- middleware imports and range path ---'
sed -n '1,120p' src/middleware.js
sed -n '930,1175p' src/middleware.js
printf '%s\n' '--- public callback declarations ---'
sed -n '1,100p' src/index.js
sed -n '160,205p' types/index.d.ts
printf '%s\n' '--- README callback documentation ---'
sed -n '318,345p' README.md

Repository: webpack/webpack-dev-middleware

Length of output: 44636


Expose multipart response data as Readable.

createMultipartBody returns Readable.from(body()), and the multipart branch passes that value to modifyResponseData. The callback contract still declares Buffer | ReadStream. A callback that uses a ReadStream-specific property can fail for multipart responses.

Update ResponseData, ModifyResponseData, and the README to use generic Readable. Keep Buffer; fs.ReadStream remains supported because it is a Readable.

🐛 Suggested fix
- * @property {Buffer | ReadStream} data data
+ * @property {Buffer | Readable} data data
...
- * @param {Buffer | ReadStream} data data
+ * @param {Buffer | Readable} data data

Apply the equivalent changes in types/index.d.ts, import or define Readable from node:stream, and update the README from ReadStream to Readable.

/** @type {number} */
(byteLength),
));
Expand Down
104 changes: 104 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

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

/** @typedef {import("./index").IncomingMessage} IncomingMessage */
/** @typedef {import("./index").ServerResponse} ServerResponse */
Expand Down Expand Up @@ -566,6 +567,108 @@ function destroyStream(stream, suppress) {
}
}

// A `Range` header with several ranges is answered with one part per range
// rather than with the whole file. Whether that applies is the caller's
// decision: ranges that cover the whole file, and more ranges than a cache or
// a client can use, stay a regular response instead.

/**
* @param {object} options options
* @param {string} options.filename filename
* @param {OutputFileSystem} options.outputFileSystem output file system
* @param {import("range-parser").Range[]} options.ranges ranges
* @param {number} options.size size
* @param {string | false} options.contentType content type
* @returns {{ boundary: string, byteLength: number, bufferOrStream: Readable }} result
*/
function createMultipartBody({
filename,
outputFileSystem,
ranges,
size,
contentType,
}) {
const boundary = crypto.randomBytes(12).toString("hex");

/** @type {{ start: number, end: number, head: Buffer }[]} */
const parts = ranges.map((range) => ({
start: range.start,
end: range.end,
head: Buffer.from(
`\r\n--${boundary}\r\n${contentType ? `Content-Type: ${contentType}\r\n` : ""}Content-Range: bytes ${range.start}-${range.end}/${size}\r\n\r\n`,
),
}));

const tail = Buffer.from(`\r\n--${boundary}--\r\n`);

const byteLength =
parts.reduce(
(total, part) => total + part.head.length + (part.end - part.start + 1),
0,
) + tail.length;

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

/** @type {Buffer | undefined} */
let cachedFullBuffer;
if (!isFsSupportsStream) {
cachedFullBuffer = /** @type {Buffer} */ (
outputFileSystem.readFileSync(filename)
);
}

/**
* Each part head, each part body, then the closing boundary.
*/
async function* body() {
for (const part of parts) {
yield part.head;

if (isFsSupportsStream) {
const { bufferOrStream } = createReadStreamOrReadFile(
filename,
outputFileSystem,
part.start,
part.end,
);

if (
typeof (
/** @type {import("fs").ReadStream} */ (bufferOrStream).pipe
) === "function"
) {
try {
yield* /** @type {import("fs").ReadStream} */ (bufferOrStream);
} finally {
destroyStream(
/** @type {import("fs").ReadStream} */ (bufferOrStream),
true,
);
}
} else {
yield /** @type {Buffer} */ (bufferOrStream).subarray(
part.start,
part.end + 1,
);
}
} else {
yield /** @type {Buffer} */ (cachedFullBuffer).subarray(
part.start,
part.end + 1,
);
}
}
yield tail;
}

return {
boundary,
byteLength,
bufferOrStream: Readable.from(body()),
};
}

/**
* @template {ServerResponse & ExpectedServerResponse} Response
* @param {Response} res res
Expand Down Expand Up @@ -1781,6 +1884,7 @@ module.exports = {
applyCors,
clientQuery,
createMimeTypes,
createMultipartBody,
createReadStreamOrReadFile,
destroyStream,
escapeHtml,
Expand Down
57 changes: 56 additions & 1 deletion test/middleware.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1294,11 +1294,66 @@ describe.each([
expect(response.statusCode).toBe(200);
});

it('should return the "200" code for the "GET" request with multiple range header which is ignored', async () => {
it('should return the "206" code for the "GET" request with multiple range header', async () => {
const response = await req
.get("/bundle.js")
.set("Range", "bytes=3000-3100,3200-3300");

expect(response.statusCode).toBe(206);
expect(response.headers["content-type"]).toMatch(
/multipart\/byteranges; boundary=/,
);
expect(response.headers["content-length"]).toBeDefined();
});

it("should return multipart 206 for two separate ranges", async () => {
const response = await req
.get("/bundle.js")
.set("Range", "bytes=0-9,20-29");

expect(response.statusCode).toBe(206);
expect(response.headers["content-type"]).toMatch(
/multipart\/byteranges; boundary=/,
);
expect(response.headers["content-length"]).toBeDefined();
});

it("should return 206 with multipart Content-Type for HEAD with multiple ranges", async () => {
const response = await req
.head("/bundle.js")
.set("Range", "bytes=0-9,20-29");

expect(response.statusCode).toBe(206);
expect(response.headers["content-type"]).toMatch(
/multipart\/byteranges; boundary=/,
);
});

it("should return 206 with only valid range when one is satisfiable and one is unsatisfiable", async () => {
const response = await req
.get("/bundle.js")
.set("Range", "bytes=0-9,9999999-");

expect(response.statusCode).toBe(206);
});

it("should return 416 when all ranges are unsatisfiable", async () => {
const response = await req
.get("/bundle.js")
.set("Range", "bytes=9999999-10000000,19999999-20000000");

expect(response.statusCode).toBe(416);
});

it("should return 200 when more ranges than cap", async () => {
const ranges = [];
for (let i = 0; i < 201; i++) {
ranges.push(`bytes=${i * 2}-${i * 2 + 1}`);
}
const response = await req
.get("/bundle.js")
.set("Range", ranges.join(", "));
Comment on lines +1350 to +1355

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Generate 201 separate ranges for the cap test.

ranges.join(", ") repeats bytes= after the first entry, so range-parser discards those entries. Changing only that prefix is insufficient: the generated ranges are adjacent, and combine: true merges them. Use one bytes= prefix and leave a gap between ranges. The current test cannot reach the 201-range fallback and can fail its 200 assertion. (github.com)

Proposed test input
 for (let i = 0; i < 201; i++) {
-  ranges.push(`bytes=${i * 2}-${i * 2 + 1}`);
+  ranges.push(`${i * 3}-${i * 3 + 1}`);
 }
 const response = await req
   .get("/bundle.js")
-  .set("Range", ranges.join(", "));
+  .set("Range", `bytes=${ranges.join(",")}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (let i = 0; i < 201; i++) {
ranges.push(`bytes=${i * 2}-${i * 2 + 1}`);
}
const response = await req
.get("/bundle.js")
.set("Range", ranges.join(", "));
for (let i = 0; i < 201; i++) {
ranges.push(`${i * 3}-${i * 3 + 1}`);
}
const response = await req
.get("/bundle.js")
.set("Range", `bytes=${ranges.join(",")}`);


expect(response.statusCode).toBe(200);
});

Expand Down
27 changes: 27 additions & 0 deletions types/utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,32 @@ export function clientQuery(
export function createMimeTypes(
extra?: Record<string, string> | undefined,
): MimeTypes;
/**
* @param {object} options options
* @param {string} options.filename filename
* @param {OutputFileSystem} options.outputFileSystem output file system
* @param {import("range-parser").Range[]} options.ranges ranges
* @param {number} options.size size
* @param {string | false} options.contentType content type
* @returns {{ boundary: string, byteLength: number, bufferOrStream: Readable }} result
*/
export function createMultipartBody({
filename,
outputFileSystem,
ranges,
size,
contentType,
}: {
filename: string;
outputFileSystem: OutputFileSystem;
ranges: import("range-parser").Range[];
size: number;
contentType: string | false;
}): {
boundary: string;
byteLength: number;
bufferOrStream: Readable;
};
/**
* @param {string} filename filename
* @param {OutputFileSystem} outputFileSystem output file system
Expand Down Expand Up @@ -583,3 +609,4 @@ export function setState<
export function setStatusCode<
Response extends ServerResponse & ExpectedServerResponse,
>(res: Response, code: number): void;
import { Readable } from "node:stream";