From 34026e6295c58b5b6b7b13873c0e03e0215001d8 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Mon, 4 May 2026 11:09:47 -0400 Subject: [PATCH 1/4] No-send --- package-lock.json | 135 ++++--------------------------------- package.json | 3 +- server.js | 74 ++++++++++++++++++-- test/testServerRequests.js | 2 +- 4 files changed, 86 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f2cb1b..36b6854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "mime": "^4.1.0", "minimist": "^1.2.8", "morphdom": "^2.7.8", - "send": "^1.2.1", + "range-parser": "^1.2.1", + "ssri": "^13.0.1", "urlpattern-polyfill": "^10.1.0", "ws": "^8.21.0" }, @@ -712,15 +713,6 @@ } } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -826,15 +818,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", @@ -939,15 +922,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1030,26 +1004,6 @@ "dev": true, "license": "ISC" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -1097,12 +1051,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/irregular-plurals": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-4.2.0.tgz", @@ -1359,31 +1307,6 @@ "node": ">=16" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -1426,7 +1349,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -1745,32 +1667,6 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -1787,12 +1683,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1843,6 +1733,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -1974,15 +1876,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index e7f18f3..48e0eb0 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "mime": "^4.1.0", "minimist": "^1.2.8", "morphdom": "^2.7.8", - "send": "^1.2.1", + "range-parser": "^1.2.1", + "ssri": "^13.0.1", "urlpattern-polyfill": "^10.1.0", "ws": "^8.21.0" }, diff --git a/server.js b/server.js index 10e3d53..dbca011 100644 --- a/server.js +++ b/server.js @@ -10,7 +10,7 @@ import "urlpattern-polyfill"; import finalhandler from "finalhandler"; import WebSocket, { WebSocketServer } from "ws"; import mime from "mime"; -import send from "send"; +import parseRange from "range-parser"; import chokidar from "chokidar"; import { TemplatePath, isPlainObject } from "@11ty/eleventy-utils"; import debugUtil from "debug"; @@ -653,6 +653,20 @@ export default class EleventyDevServer { } // This runs at the end of the middleware chain + /** + * @param {String} type + * @param {number} size + * @param {number=} range + */ + #contentRange(type, size, range) { + return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size + } + + /** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').OutgoingMessage} res + * This runs at the end of the middleware chain + */ eleventyProjectMiddleware(req, res) { // Known issue with `finalhandler` and HTTP/2: // UnsupportedWarning: Status message is not supported by HTTP/2 (RFC7540 8.1.2.4) @@ -682,11 +696,61 @@ export default class EleventyDevServer { if (match) { if (match.statusCode === 200 && match.filepath) { // Content-Range request, probably Safari trying to stream video - if (req.headers.range) { - return send(req, match.filepath).pipe(res); + // If the client includes an If-Range header, + // serve them the whole thing. We don't include + // last-modified or etags headers, so these + // requests are invalid. + if (req.headers.range && !req.headers['if-range']) { + fs.stat(match.filepath, (err, stat) => { + if (err) { + res.statusCode = 404; + res.send('File not found'); + return; + } + + const len = stat.size; + + const ranges = parseRange(len, req.headers.range, { + combine: true + }) + + // unsatisfiable + if (ranges === -1) { + // 416 Requested Range Not Satisfiable + res.statusCode = 416; + res.setHeader('Content-Range', this.#contentRange('bytes', len)) + return res.end(); + } + + // valid (syntactically invalid/multiple ranges are treated as a regular response) + if (ranges !== -2 && ranges.length === 1) { + // Content-Range + res.statusCode = 206 + res.setHeader('Content-Range', this.#contentRange('bytes', len, ranges[0])) + + // adjust for requested range + let start = ranges[0].start + len = ranges[0].end - ranges[0].start + 1 + let end = Math.max(offset, offset + len - 1) + res.setHeader('Content-Length', len) + if (req.method === 'HEAD') { + res.end() + return + } + const stream = fs.createReadStream(match.filepath, { + start, end + }); + stream.pipe(res); + const cleanup = () => { + stream.destroy(); + } + stream.on('error', cleanup); + stream.on('end', cleanup); + } + }) + } else { + return this.renderFile(match.filepath, res); } - - return this.renderFile(match.filepath, res); } // Redirects, usually for trailing slash to .html stuff diff --git a/test/testServerRequests.js b/test/testServerRequests.js index cb3cc8b..d9bd6fb 100644 --- a/test/testServerRequests.js +++ b/test/testServerRequests.js @@ -310,7 +310,7 @@ test("Content-Type header via middleware", async t => { await server.close(); }); -test("Content-Range request", async (t) => { +test.only("Content-Range request", async (t) => { let server = new EleventyDevServer( "test-server", "./test/stubs/", From c072c5c9ce9078b14dc1b4c5cfae9515406418a8 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Sun, 10 May 2026 14:14:55 -0400 Subject: [PATCH 2/4] Fix compatibility --- server.js | 20 +++++++++++--------- test/testServerRequests.js | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/server.js b/server.js index dbca011..32f06b7 100644 --- a/server.js +++ b/server.js @@ -701,31 +701,34 @@ export default class EleventyDevServer { // last-modified or etags headers, so these // requests are invalid. if (req.headers.range && !req.headers['if-range']) { - fs.stat(match.filepath, (err, stat) => { + return fs.stat(match.filepath, (err, stat) => { if (err) { res.statusCode = 404; res.send('File not found'); return; } - const len = stat.size; + let len = stat.size; + let offset = 0; const ranges = parseRange(len, req.headers.range, { combine: true }) + // Tell clients that they can send ranges. + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Cache-Control', 'public, max-age=0'); + // unsatisfiable if (ranges === -1) { // 416 Requested Range Not Satisfiable res.statusCode = 416; res.setHeader('Content-Range', this.#contentRange('bytes', len)) return res.end(); - } - + } else if (ranges !== -2 && ranges.length === 1) { // valid (syntactically invalid/multiple ranges are treated as a regular response) - if (ranges !== -2 && ranges.length === 1) { // Content-Range - res.statusCode = 206 + res.statusCode = 206; res.setHeader('Content-Range', this.#contentRange('bytes', len, ranges[0])) // adjust for requested range @@ -747,10 +750,9 @@ export default class EleventyDevServer { stream.on('error', cleanup); stream.on('end', cleanup); } - }) - } else { - return this.renderFile(match.filepath, res); + }); } + return this.renderFile(match.filepath, res); } // Redirects, usually for trailing slash to .html stuff diff --git a/test/testServerRequests.js b/test/testServerRequests.js index d9bd6fb..cb3cc8b 100644 --- a/test/testServerRequests.js +++ b/test/testServerRequests.js @@ -310,7 +310,7 @@ test("Content-Type header via middleware", async t => { await server.close(); }); -test.only("Content-Range request", async (t) => { +test("Content-Range request", async (t) => { let server = new EleventyDevServer( "test-server", "./test/stubs/", From b494027c15060740babe475a6caf59976e1858b7 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Wed, 5 Aug 2026 11:20:40 -0400 Subject: [PATCH 3/4] Merge --- package-lock.json | 26 +++++++++----------------- package.json | 3 +-- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36b6854..7972c63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,7 @@ "mime": "^4.1.0", "minimist": "^1.2.8", "morphdom": "^2.7.8", - "range-parser": "^1.2.1", - "ssri": "^13.0.1", + "range-parser": "^1.3.0", "urlpattern-polyfill": "^10.1.0", "ws": "^8.21.0" }, @@ -1349,6 +1348,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -1576,12 +1576,16 @@ "license": "MIT" }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/readdirp": { @@ -1733,18 +1737,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/ssri": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", - "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", diff --git a/package.json b/package.json index 48e0eb0..a1fb7cc 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,7 @@ "mime": "^4.1.0", "minimist": "^1.2.8", "morphdom": "^2.7.8", - "range-parser": "^1.2.1", - "ssri": "^13.0.1", + "range-parser": "^1.3.0", "urlpattern-polyfill": "^10.1.0", "ws": "^8.21.0" }, From b21ec9a11f4edb32a208ece96acfebb555d75062 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Wed, 5 Aug 2026 11:18:06 -0400 Subject: [PATCH 4/4] Fix implementation warts, improve test coverage --- server.js | 28 +++++++++++--- test/testServerRequests.js | 79 +++++++++++++++++++++++++++++++------- 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/server.js b/server.js index 32f06b7..66b02af 100644 --- a/server.js +++ b/server.js @@ -22,6 +22,7 @@ const require = createRequire(import.meta.url); const pkg = require("./package.json"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const debug = debugUtil("Eleventy:DevServer"); +const BYTES_RANGE_REGEXP = /^ *bytes=/ const DEFAULT_OPTIONS = { port: 8080, @@ -502,6 +503,10 @@ export default class EleventyDevServer { return (content || "") + script; } + /** + * Infer a content-type from a filepath. + * @returns {string|undefined} + */ getFileContentType(filepath, res) { let contentType = res.getHeader("Content-Type"); @@ -656,7 +661,7 @@ export default class EleventyDevServer { /** * @param {String} type * @param {number} size - * @param {number=} range + * @param {{start: number, end: number} | undefined} range */ #contentRange(type, size, range) { return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size @@ -700,16 +705,16 @@ export default class EleventyDevServer { // serve them the whole thing. We don't include // last-modified or etags headers, so these // requests are invalid. - if (req.headers.range && !req.headers['if-range']) { + if (BYTES_RANGE_REGEXP.test(req.headers.range) && !req.headers['if-range']) { return fs.stat(match.filepath, (err, stat) => { if (err) { res.statusCode = 404; - res.send('File not found'); + res.end('File not found'); return; } + let contentType = this.getFileContentType(match.filepath, res); let len = stat.size; - let offset = 0; const ranges = parseRange(len, req.headers.range, { combine: true @@ -718,6 +723,9 @@ export default class EleventyDevServer { // Tell clients that they can send ranges. res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Cache-Control', 'public, max-age=0'); + if (contentType) { + res.setHeader("Content-Type", contentType); + } // unsatisfiable if (ranges === -1) { @@ -734,7 +742,7 @@ export default class EleventyDevServer { // adjust for requested range let start = ranges[0].start len = ranges[0].end - ranges[0].start + 1 - let end = Math.max(offset, offset + len - 1) + let end = ranges[0].end res.setHeader('Content-Length', len) if (req.method === 'HEAD') { res.end() @@ -746,9 +754,14 @@ export default class EleventyDevServer { stream.pipe(res); const cleanup = () => { stream.destroy(); + res.destroy(); } + res.on('close', cleanup); stream.on('error', cleanup); stream.on('end', cleanup); + } else { + // Just send multi-range requests as full files. + return this.renderFile(match.filepath, res); } }); } @@ -943,8 +956,11 @@ export default class EleventyDevServer { return this.getServerUrlRaw(host, pathname, false); } + _portPromise = null; + async getPort() { - return new Promise(resolve => { + if (this._portPromise) return this._portPromise; + return this._portPromise = new Promise(resolve => { this.server.on("listening", (e) => { let { port } = this._server.address(); resolve(port); diff --git a/test/testServerRequests.js b/test/testServerRequests.js index cb3cc8b..798a0d2 100644 --- a/test/testServerRequests.js +++ b/test/testServerRequests.js @@ -12,21 +12,28 @@ function getOptions(options = {}) { return options; } -async function makeRequestTo(t, server, path) { +// Available status codes can be found here: http.STATUS_CODES +const successCodes = new Set([ + 200, // OK + 206, // Partial Content +]); + +async function makeRequestTo(t, server, path, extras = {}) { let port = await server.getPort(); - return new Promise(resolve => { + return new Promise((resolve, reject) => { const options = { hostname: 'localhost', port, path, method: 'GET', + ...extras }; http.get(options, (res) => { const { statusCode } = res; - if(statusCode !== 200) { - throw new Error("Invalid status code" + statusCode); + if(!successCodes.has(statusCode)) { + return reject(new Error("Invalid status code" + statusCode)); } res.setEncoding('utf8'); @@ -46,7 +53,7 @@ async function makeRequestTo(t, server, path) { async function fetchHeadersForRequest(t, server, path, extras) { let port = await server.getPort(); - return new Promise(resolve => { + return new Promise((resolve, reject) => { const options = { hostname: 'localhost', port, @@ -55,15 +62,10 @@ async function fetchHeadersForRequest(t, server, path, extras) { ...extras, }; - // Available status codes can be found here: http.STATUS_CODES - const successCodes = [ - 200, // OK - 206, // Partial Content - ]; http.get(options, (res) => { const { statusCode } = res; - if (!successCodes.includes(statusCode)) { - throw new Error("Invalid status code " + statusCode); + if (!successCodes.has(statusCode)) { + return reject(new Error("Invalid status code " + statusCode)); } let headers = res.headers; @@ -319,15 +321,64 @@ test("Content-Range request", async (t) => { server.serve(8100); const options = { headers: { Range: "bytes=0-48" } }; + let headers = await fetchHeadersForRequest(t, server, `/index.html`, options); + t.true("accept-ranges" in headers); + t.true(headers["accept-ranges"] === "bytes"); + t.true("content-range" in headers); + t.true(headers["content-range"].startsWith("bytes 0-48/")); + + let data = await makeRequestTo(t, server, `/index.html`, options); + t.is(data.length, 49); + + await server.close(); +}); + +test("Content-Range request multiple is handled as full", async (t) => { + let server = new EleventyDevServer( + "test-server", + "./test/stubs/", + getOptions() + ); + server.serve(8100); + + const options = { headers: { Range: "bytes=0-10,20-30" } }; + let headers = await fetchHeadersForRequest(t, server, `/index.html`, options); + t.true("accept-ranges" in headers); + t.true(headers["accept-ranges"] === "bytes"); + + await server.close(); +}); + +test("Content-Range request invalid", async (t) => { + let server = new EleventyDevServer( + "test-server", + "./test/stubs/", + getOptions() + ); + server.serve(8100); + + const options = { headers: { Range: "bytes=xxx" } }; let data = await fetchHeadersForRequest(t, server, `/index.html`, options); t.true("accept-ranges" in data); t.true(data["accept-ranges"] === "bytes"); - t.true("content-range" in data); - t.true(data["content-range"].startsWith("bytes 0-48/")); await server.close(); }); +test("Content-Range request invalid reversed", async (t) => { + let server = new EleventyDevServer( + "test-server", + "./test/stubs/", + getOptions() + ); + server.serve(8100); + const options = { headers: { Range: "bytes=100-0" } }; + await t.throwsAsync(() => fetchHeadersForRequest(t, server, `/index.html`, options), { + instanceOf: Error, message: 'Invalid status code 416' + }) + await server.close(); +}) + test("Standard request does not include range headers", async (t) => { let server = new EleventyDevServer( "test-server",