From 77c8217095729b40ec6ea6ee71331619c4b3c4d5 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Mon, 4 May 2026 11:09:47 -0400 Subject: [PATCH 1/2] No-send --- package-lock.json | 121 +------------------------------------ package.json | 2 +- server.js | 75 +++++++++++++++++++++-- test/testServerRequests.js | 2 +- 4 files changed, 72 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a12a90..5805483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "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.20.0" @@ -857,15 +857,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.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -978,15 +969,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", @@ -1108,15 +1090,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "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", @@ -1202,26 +1175,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", @@ -1279,12 +1232,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": "3.5.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", @@ -1542,31 +1489,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", @@ -1993,32 +1915,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", @@ -2035,12 +1931,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/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2339,15 +2229,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 28b3afe..c9d9c7f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "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.20.0" diff --git a/server.js b/server.js index a4f8233..58bacc8 100644 --- a/server.js +++ b/server.js @@ -10,7 +10,7 @@ import finalhandler from "finalhandler"; import WebSocket, { WebSocketServer } from "ws"; import mime from "mime"; import ssri from "ssri"; -import send from "send"; +import parseRange from "range-parser"; import chokidar from "chokidar"; import { TemplatePath, isPlainObject } from "@11ty/eleventy-utils"; import debugUtil from "debug"; @@ -638,7 +638,20 @@ export default class EleventyDevServer { next(); } - // 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) @@ -668,11 +681,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 97e6adf..8a502f1 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 00aee6c0c739487147f7eed8205869874634ce67 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Sun, 10 May 2026 14:14:55 -0400 Subject: [PATCH 2/2] 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 58bacc8..41e3d7f 100644 --- a/server.js +++ b/server.js @@ -686,31 +686,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 @@ -732,10 +735,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 8a502f1..97e6adf 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/",