Repository navigation
feat: add multipart range requests #2479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| "language": "en,en-gb", | ||
| "words": [ | ||
| "apos", | ||
| "byteranges", | ||
| "cachable", | ||
| "cexoso", | ||
| "commitlint", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ const querystring = require("node:querystring"); | |
| const { finished } = require("node:stream"); | ||
|
|
||
| const { | ||
| createMultipartBody, | ||
| createReadStreamOrReadFile, | ||
| destroyStream, | ||
| escapeHtml, | ||
|
|
@@ -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", | ||
|
|
@@ -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; | ||
|
|
@@ -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); | ||
| const multipartResult = createMultipartBody({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) { | ||
|
|
@@ -1106,7 +1145,7 @@ function wrapper(context) { | |
| context.options.modifyResponseData( | ||
| req, | ||
| res, | ||
| bufferOrStream, | ||
| /** @type {Buffer | ReadStream} */ (bufferOrStream), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.mdRepository: 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.mdRepository: webpack/webpack-dev-middleware Length of output: 44636 Expose multipart response data as
Update 🐛 Suggested fix- * @property {Buffer | ReadStream} data data
+ * @property {Buffer | Readable} data data
...
- * @param {Buffer | ReadStream} data data
+ * @param {Buffer | Readable} data dataApply the equivalent changes in |
||
| /** @type {number} */ | ||
| (byteLength), | ||
| )); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
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
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| expect(response.statusCode).toBe(200); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
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
headersoverrides Content-Type, ormime.defaultsupplies it for an unknown extension, this lookup does not return the Content-Type selected earlier inprocessRequest. 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 tocreateMultipartBody. RFC 9110 says each part should carry the Content-Type that the corresponding200response would have carried. (rfc-editor.org)