diff --git a/package-lock.json b/package-lock.json index 0f2cb1b..7972c63 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.3.0", "urlpattern-polyfill": "^10.1.0", "ws": "^8.21.0" }, @@ -712,15 +712,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 +817,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 +921,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 +1003,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 +1050,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 +1306,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", @@ -1654,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": { @@ -1745,32 +1671,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 +1687,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", @@ -1974,15 +1868,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..a1fb7cc 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "mime": "^4.1.0", "minimist": "^1.2.8", "morphdom": "^2.7.8", - "send": "^1.2.1", + "range-parser": "^1.3.0", "urlpattern-polyfill": "^10.1.0", "ws": "^8.21.0" }, diff --git a/server.js b/server.js index 10e3d53..66b02af 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"; @@ -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"); @@ -653,6 +658,20 @@ export default class EleventyDevServer { } // This runs at the end of the middleware chain + /** + * @param {String} type + * @param {number} size + * @param {{start: number, end: number} | undefined} 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,10 +701,70 @@ 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 (BYTES_RANGE_REGEXP.test(req.headers.range) && !req.headers['if-range']) { + return fs.stat(match.filepath, (err, stat) => { + if (err) { + res.statusCode = 404; + res.end('File not found'); + return; + } + + let contentType = this.getFileContentType(match.filepath, res); + let len = stat.size; + + 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'); + if (contentType) { + res.setHeader("Content-Type", contentType); + } + + // 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) + // 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 = ranges[0].end + 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(); + 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); + } + }); } - return this.renderFile(match.filepath, res); } @@ -877,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",