From 1f54424698ff074a9d3b340853fe32758340e448 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 29 Mar 2024 17:21:31 +0300 Subject: [PATCH] test: more (#1800) --- src/index.js | 4 +- src/middleware.js | 321 ++++++++++++++++++++------------ src/utils/compatibleAPI.js | 44 ++++- src/utils/getFilenameFromUrl.js | 39 +--- src/utils/memorize.js | 43 +++++ test/middleware.test.js | 193 +++++++++++++++---- types/index.d.ts | 8 +- types/utils/compatibleAPI.d.ts | 16 ++ types/utils/memorize.d.ts | 26 +++ 9 files changed, 495 insertions(+), 199 deletions(-) create mode 100644 src/utils/memorize.js create mode 100644 types/utils/memorize.d.ts diff --git a/src/index.js b/src/index.js index d0c634c40..de2419f10 100644 --- a/src/index.js +++ b/src/index.js @@ -59,7 +59,7 @@ const noop = () => {}; /** * @typedef {Object} ResponseData - * @property {string | Buffer | ReadStream} data + * @property {Buffer | ReadStream} data * @property {number} byteLength */ @@ -69,7 +69,7 @@ const noop = () => {}; * @callback ModifyResponseData * @param {RequestInternal} req * @param {ResponseInternal} res - * @param {string | Buffer | ReadStream} data + * @param {Buffer | ReadStream} data * @param {number} byteLength * @return {ResponseData} */ diff --git a/src/middleware.js b/src/middleware.js index e1bf81125..dcf1217ef 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -5,9 +5,15 @@ const mime = require("mime-types"); const onFinishedStream = require("on-finished"); const getFilenameFromUrl = require("./utils/getFilenameFromUrl"); -const { setStatusCode, send, pipe } = require("./utils/compatibleAPI"); +const { + setStatusCode, + send, + pipe, + createReadStreamOrReadFileSync, +} = require("./utils/compatibleAPI"); const ready = require("./utils/ready"); const parseTokenList = require("./utils/parseTokenList"); +const memorize = require("./utils/memorize"); /** @typedef {import("./index.js").NextFunction} NextFunction */ /** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ @@ -84,6 +90,21 @@ const statuses = { 500: "Internal Server Error", }; +const parseRangeHeaders = memorize( + /** + * @param {string} value + * @returns {import("range-parser").Result | import("range-parser").Ranges} + */ + (value) => { + const [len, rangeHeader] = value.split("|"); + + // eslint-disable-next-line global-require + return require("range-parser")(Number(len), rangeHeader, { + combine: true, + }); + }, +); + /** * @template {IncomingMessage} Request * @template {ServerResponse} Response @@ -141,7 +162,8 @@ function wrapper(context) { // eslint-disable-next-line global-require const escapeHtml = require("./utils/escapeHtml"); const content = statuses[status] || String(status); - let document = ` + let document = Buffer.from( + ` @@ -150,7 +172,9 @@ function wrapper(context) {
${escapeHtml(content)}
-`; +`, + "utf-8", + ); // Clear existing headers const headers = res.getHeaderNames(); @@ -182,7 +206,7 @@ function wrapper(context) { if (options && options.modifyResponseData) { ({ data: document, byteLength } = - /** @type {{data: string, byteLength: number }} */ + /** @type {{ data: Buffer, byteLength: number }} */ (options.modifyResponseData(req, res, document, byteLength))); } @@ -267,9 +291,16 @@ function wrapper(context) { return false; } - // if-none-match + // fields const noneMatch = req.headers["if-none-match"]; + const modifiedSince = req.headers["if-modified-since"]; + // unconditional request + if (!noneMatch && !modifiedSince) { + return false; + } + + // if-none-match if (noneMatch && noneMatch !== "*") { if (!resHeaders.etag) { return false; @@ -305,21 +336,15 @@ function wrapper(context) { } // if-modified-since - const modifiedSince = req.headers["if-modified-since"]; - if (modifiedSince) { const lastModified = resHeaders["last-modified"]; - const parsedHttpDate = parseHttpDate(modifiedSince); // A recipient MUST ignore the If-Modified-Since header field if the // received field-value is not a valid HTTP-date, or if the request // method is neither GET nor HEAD. - if (isNaN(parsedHttpDate)) { - return true; - } - const modifiedStale = - !lastModified || !(parseHttpDate(lastModified) <= parsedHttpDate); + !lastModified || + !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)); if (modifiedStale) { return false; @@ -361,6 +386,43 @@ function wrapper(context) { return parseHttpDate(lastModified) <= parseHttpDate(ifRange); } + /** + * @returns {string | undefined} + */ + function getRangeHeader() { + const rage = req.headers.range; + + if (rage && BYTES_RANGE_REGEXP.test(rage)) { + return rage; + } + + // eslint-disable-next-line no-undefined + return undefined; + } + + /** + * @param {import("range-parser").Range} range + * @returns {[number, number]} + */ + function getOffsetAndLenFromRange(range) { + const offset = range.start; + const len = range.end - range.start + 1; + + return [offset, len]; + } + + /** + * @param {number} offset + * @param {number} len + * @returns {[number, number]} + */ + function calcStartAndEnd(offset, len) { + const start = offset; + const end = Math.max(offset, offset + len - 1); + + return [start, end]; + } + async function processRequest() { // Pipe and SendFile /** @type {import("./utils/getFilenameFromUrl").Extra} */ @@ -389,6 +451,11 @@ function wrapper(context) { return; } + const { size } = /** @type {import("fs").Stats} */ (extra.stats); + + let len = size; + let offset = 0; + // Send logic let { headers } = context.options; @@ -433,125 +500,75 @@ function wrapper(context) { res.setHeader("Accept-Ranges", "bytes"); } - let len = /** @type {import("fs").Stats} */ (extra.stats).size; - let offset = 0; - - const rangeHeader = /** @type {string} */ (req.headers.range); - - if (rangeHeader && BYTES_RANGE_REGEXP.test(rangeHeader)) { - let parsedRanges = - /** @type {import("range-parser").Ranges | import("range-parser").Result | []} */ - ( - // eslint-disable-next-line global-require - require("range-parser")(len, rangeHeader, { - combine: true, - }) - ); - - // If-Range support - if (!isRangeFresh()) { - parsedRanges = []; - } - - if (parsedRanges === -1) { - context.logger.error("Unsatisfiable range for 'Range' header."); - - res.setHeader( - "Content-Range", - getValueContentRangeHeader("bytes", len), - ); - - sendError(416, { - headers: { - "Content-Range": res.getHeader("Content-Range"), - }, - modifyResponseData: context.options.modifyResponseData, - }); - - return; - } else if (parsedRanges === -2) { - context.logger.error( - "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.", - ); - } - - if (parsedRanges !== -2 && parsedRanges.length === 1) { - // Content-Range - setStatusCode(res, 206); - res.setHeader( - "Content-Range", - getValueContentRangeHeader( - "bytes", - len, - /** @type {import("range-parser").Ranges} */ (parsedRanges)[0], - ), - ); + if (context.options.lastModified && !res.getHeader("Last-Modified")) { + const modified = + /** @type {import("fs").Stats} */ + (extra.stats).mtime.toUTCString(); - offset += parsedRanges[0].start; - len = parsedRanges[0].end - parsedRanges[0].start + 1; - } + res.setHeader("Last-Modified", modified); } - const start = offset; - const end = Math.max(offset, offset + len - 1); + /** @type {number} */ + let start; + /** @type {number} */ + let end; - // Stream logic - const isFsSupportsStream = - typeof context.outputFileSystem.createReadStream === "function"; - - /** @type {string | Buffer | ReadStream} */ + /** @type {undefined | Buffer | ReadStream} */ let bufferOrStream; + /** @type {number} */ let byteLength; - try { - if (isFsSupportsStream) { - bufferOrStream = - /** @type {import("fs").createReadStream} */ - (context.outputFileSystem.createReadStream)(filename, { - start, - end, - }); + const rangeHeader = getRangeHeader(); - // Handle files with zero bytes - byteLength = end === 0 ? 0 : end - start + 1; + if (context.options.etag && !res.getHeader("ETag")) { + /** @type {import("fs").Stats | Buffer | ReadStream | undefined} */ + let value; + + if (context.options.etag === "weak") { + value = /** @type {import("fs").Stats} */ (extra.stats); } else { - bufferOrStream = /** @type {import("fs").readFileSync} */ ( - context.outputFileSystem.readFileSync - )(filename); - ({ byteLength } = bufferOrStream); - } - } catch (_ignoreError) { - await goNext(); + if (rangeHeader) { + const parsedRanges = + /** @type {import("range-parser").Ranges | import("range-parser").Result} */ + (parseRangeHeaders(`${size}|${rangeHeader}`)); + + if ( + parsedRanges !== -2 && + parsedRanges !== -1 && + parsedRanges.length === 1 + ) { + [offset, len] = getOffsetAndLenFromRange(parsedRanges[0]); + } + } - return; - } + [start, end] = calcStartAndEnd(offset, len); - if (context.options.lastModified && !res.getHeader("Last-Modified")) { - const modified = - /** @type {import("fs").Stats} */ - (extra.stats).mtime.toUTCString(); + try { + const result = createReadStreamOrReadFileSync( + filename, + context.outputFileSystem, + start, + end, + ); - res.setHeader("Last-Modified", modified); - } + value = result.bufferOrStream; + ({ bufferOrStream, byteLength } = result); + } catch (_err) { + // Ignore here + } + } - if (context.options.etag && !res.getHeader("ETag")) { - const value = - context.options.etag === "weak" - ? /** @type {import("fs").Stats} */ (extra.stats) - : bufferOrStream; + if (value) { + // eslint-disable-next-line global-require + const result = await require("./utils/etag")(value); - // eslint-disable-next-line global-require - const val = await require("./utils/etag")(value); + // Because we already read stream, we can cache buffer to avoid extra read from fs + if (result.buffer) { + bufferOrStream = result.buffer; + } - if (val.buffer) { - bufferOrStream = val.buffer; + res.setHeader("ETag", result.hash); } - - res.setHeader("ETag", val.hash); } // Conditional GET support @@ -592,16 +609,88 @@ function wrapper(context) { } } + if (rangeHeader) { + let parsedRanges = + /** @type {import("range-parser").Ranges | import("range-parser").Result | []} */ + (parseRangeHeaders(`${size}|${rangeHeader}`)); + + // If-Range support + if (!isRangeFresh()) { + parsedRanges = []; + } + + if (parsedRanges === -1) { + context.logger.error("Unsatisfiable range for 'Range' header."); + + res.setHeader( + "Content-Range", + getValueContentRangeHeader("bytes", size), + ); + + sendError(416, { + headers: { + "Content-Range": res.getHeader("Content-Range"), + }, + modifyResponseData: context.options.modifyResponseData, + }); + + return; + } else if (parsedRanges === -2) { + context.logger.error( + "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.", + ); + } + + if (parsedRanges !== -2 && parsedRanges.length === 1) { + // Content-Range + setStatusCode(res, 206); + res.setHeader( + "Content-Range", + getValueContentRangeHeader( + "bytes", + size, + /** @type {import("range-parser").Ranges} */ (parsedRanges)[0], + ), + ); + + [offset, len] = getOffsetAndLenFromRange(parsedRanges[0]); + } + } + + // When strong Etag generation is enabled we already read file, so we can skip extra fs call + if (!bufferOrStream) { + [start, end] = calcStartAndEnd(offset, len); + + try { + ({ bufferOrStream, byteLength } = createReadStreamOrReadFileSync( + filename, + context.outputFileSystem, + start, + end, + )); + } catch (_ignoreError) { + await goNext(); + + return; + } + } + if (context.options.modifyResponseData) { ({ data: bufferOrStream, byteLength } = context.options.modifyResponseData( req, res, bufferOrStream, + // @ts-ignore byteLength, )); } + // @ts-ignore res.setHeader("Content-Length", byteLength); if (req.method === "HEAD") { diff --git a/src/utils/compatibleAPI.js b/src/utils/compatibleAPI.js index 2d819e8b4..ab315b6f0 100644 --- a/src/utils/compatibleAPI.js +++ b/src/utils/compatibleAPI.js @@ -62,4 +62,46 @@ function send(res, bufferOrStream) { res.end(bufferOrStream); } -module.exports = { setStatusCode, send, pipe }; +/** + * @param {string} filename + * @param {import("../index").OutputFileSystem} outputFileSystem + * @param {number} start + * @param {number} end + * @returns {{ bufferOrStream: (Buffer | import("fs").ReadStream), byteLength: number }} + */ +function createReadStreamOrReadFileSync( + filename, + outputFileSystem, + start, + end, +) { + /** @type {string | Buffer | import("fs").ReadStream} */ + let bufferOrStream; + /** @type {number} */ + let byteLength; + + // Stream logic + const isFsSupportsStream = + typeof outputFileSystem.createReadStream === "function"; + + if (isFsSupportsStream) { + bufferOrStream = + /** @type {import("fs").createReadStream} */ + (outputFileSystem.createReadStream)(filename, { + start, + end, + }); + + // Handle files with zero bytes + byteLength = end === 0 ? 0 : end - start + 1; + } else { + bufferOrStream = + /** @type {import("fs").readFileSync} */ + (outputFileSystem.readFileSync)(filename); + ({ byteLength } = bufferOrStream); + } + + return { bufferOrStream, byteLength }; +} + +module.exports = { setStatusCode, send, pipe, createReadStreamOrReadFileSync }; diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index 0cb9c05a4..319b149db 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -3,48 +3,13 @@ const { parse } = require("url"); const querystring = require("querystring"); const getPaths = require("./getPaths"); +const memorize = require("./memorize"); /** @typedef {import("../index.js").IncomingMessage} IncomingMessage */ /** @typedef {import("../index.js").ServerResponse} ServerResponse */ -const cacheStore = new WeakMap(); - -/** - * @template T - * @param {Function} fn - * @param {{ cache?: Map } | undefined} cache - * @param {(value: T) => T} callback - * @returns {any} - */ -const mem = (fn, { cache = new Map() } = {}, callback) => { - /** - * @param {any} arguments_ - * @return {any} - */ - const memoized = (...arguments_) => { - const [key] = arguments_; - const cacheItem = cache.get(key); - - if (cacheItem) { - return cacheItem.data; - } - - let result = fn.apply(this, arguments_); - result = callback(result); - - cache.set(key, { - data: result, - }); - - return result; - }; - - cacheStore.set(memoized, cache); - - return memoized; -}; // eslint-disable-next-line no-undefined -const memoizedParse = mem(parse, undefined, (value) => { +const memoizedParse = memorize(parse, undefined, (value) => { if (value.pathname) { // eslint-disable-next-line no-param-reassign value.pathname = decode(value.pathname); diff --git a/src/utils/memorize.js b/src/utils/memorize.js new file mode 100644 index 000000000..a8921157e --- /dev/null +++ b/src/utils/memorize.js @@ -0,0 +1,43 @@ +const cacheStore = new WeakMap(); + +/** + * @template T + * @param {Function} fn + * @param {{ cache?: Map } | undefined} cache + * @param {((value: T) => T)=} callback + * @returns {any} + */ +function memorize(fn, { cache = new Map() } = {}, callback) { + /** + * @param {any} arguments_ + * @return {any} + */ + const memoized = (...arguments_) => { + const [key] = arguments_; + console.log("CACHE", key); + const cacheItem = cache.get(key); + + if (cacheItem) { + return cacheItem.data; + } + + // @ts-ignore + let result = fn.apply(this, arguments_); + + if (callback) { + result = callback(result); + } + + cache.set(key, { + data: result, + }); + + return result; + }; + + cacheStore.set(memoized, cache); + + return memoized; +} + +module.exports = memorize; diff --git a/test/middleware.test.js b/test/middleware.test.js index c47a18b01..cab109ba7 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -242,6 +242,13 @@ function applyTestMiddleware(name, middlewares) { return middlewares; } +function parseHttpDate(date) { + const timestamp = date && Date.parse(date); + + // istanbul ignore next: guard against date.js Date.parse patching + return typeof timestamp === "number" ? timestamp : NaN; +} + describe.each([ ["connect", connect], ["express", express], @@ -4238,7 +4245,7 @@ describe.each([ expect(response.headers.etag.startsWith("W/")).toBe(true); }); - it('should return the "304" code for the "GET" request to the bundle file with etag and "if-match" header', async () => { + it('should return the "304" code for the "GET" request to the bundle file with etag and "if-none-match" header', async () => { const response1 = await req.get(`/bundle.js`); expect(response1.statusCode).toEqual(200); @@ -4247,18 +4254,38 @@ describe.each([ const response2 = await req .get(`/bundle.js`) - .set("if-match", response1.headers.etag); + .set("if-none-match", response1.headers.etag); expect(response2.statusCode).toEqual(304); expect(response2.headers.etag).toBeDefined(); expect(response2.headers.etag.startsWith("W/")).toBe(true); - const response3 = await req.get(`/bundle.js`).set("if-match", "test"); + const response3 = await req + .get(`/bundle.js`) + .set("if-none-match", `${response1.headers.etag}, test`); - expect(response3.statusCode).toEqual(412); + expect(response3.statusCode).toEqual(304); + expect(response3.headers.etag).toBeDefined(); + expect(response3.headers.etag.startsWith("W/")).toBe(true); + + const response4 = await req + .get(`/bundle.js`) + .set("if-none-match", "*"); + + expect(response4.statusCode).toEqual(304); + expect(response4.headers.etag).toBeDefined(); + expect(response4.headers.etag.startsWith("W/")).toBe(true); + + const response5 = await req + .get(`/bundle.js`) + .set("if-none-match", "test"); + + expect(response5.statusCode).toEqual(200); + expect(response5.headers.etag).toBeDefined(); + expect(response5.headers.etag.startsWith("W/")).toBe(true); }); - it('should return the "304" code for the "GET" request to the bundle file with etag "if-none-match" header', async () => { + it('should return the "200" code for the "GET" request to the bundle file with etag and "if-match" header', async () => { const response1 = await req.get(`/bundle.js`); expect(response1.statusCode).toEqual(200); @@ -4267,19 +4294,29 @@ describe.each([ const response2 = await req .get(`/bundle.js`) - .set("if-none-match", response1.headers.etag); + .set("if-match", response1.headers.etag); - expect(response2.statusCode).toEqual(304); + expect(response2.statusCode).toEqual(200); expect(response2.headers.etag).toBeDefined(); expect(response2.headers.etag.startsWith("W/")).toBe(true); const response3 = await req .get(`/bundle.js`) - .set("if-none-match", "test"); + .set("if-match", `${response1.headers.etag}, foo`); expect(response3.statusCode).toEqual(200); expect(response3.headers.etag).toBeDefined(); expect(response3.headers.etag.startsWith("W/")).toBe(true); + + const response4 = await req.get(`/bundle.js`).set("if-match", "*"); + + expect(response4.statusCode).toEqual(200); + expect(response4.headers.etag).toBeDefined(); + expect(response4.headers.etag.startsWith("W/")).toBe(true); + + const response5 = await req.get(`/bundle.js`).set("if-match", "test"); + + expect(response5.statusCode).toEqual(412); }); it('should return the "412" code for the "GET" request to the bundle file with etag and wrong "if-match" header', async () => { @@ -4310,10 +4347,25 @@ describe.each([ expect(response2.headers.etag).toBeDefined(); expect(response2.headers.etag.startsWith("W/")).toBe(true); }); + + it('should return the "206" code for the "GET" request with the valid range header and "if-range" header', async () => { + const response = await req + .get("/bundle.js") + .set("if-range", '"test"') + .set("Range", "bytes=3000-3500"); + + expect(response.statusCode).toEqual(200); + expect(response.headers["content-type"]).toEqual( + "application/javascript; charset=utf-8", + ); + expect(response.headers.etag).toBeDefined(); + expect(response.headers.etag.startsWith("W/")).toBe(true); + }); }); describe("should work and generate strong etag", () => { beforeEach(async () => { + const outputPath = path.resolve(__dirname, "./outputs/basic"); const compiler = getCompiler(webpackConfig); [server, req, instance] = await frameworkFactory( @@ -4324,13 +4376,21 @@ describe.each([ etag: "strong", }, ); + + instance.context.outputFileSystem.mkdirSync(outputPath, { + recursive: true, + }); + instance.context.outputFileSystem.writeFileSync( + path.resolve(outputPath, "file.txt"), + "", + ); }); afterEach(async () => { await close(server, instance); }); - it('should return the "200" code for the "GET" request to the bundle file and set weak etag', async () => { + it('should return the "200" code for the "GET" request to the bundle file and set strong etag', async () => { const response = await req.get(`/bundle.js`); expect(response.statusCode).toEqual(200); @@ -4339,6 +4399,37 @@ describe.each([ '"18c7-l/LCspQS5fbbf1kkLGOsK9FTpbg"', ); }); + + it('should return the "200" code for the "GET" request to the file.txt and set strong etag on empty file', async () => { + const response = await req.get(`/file.txt`); + + expect(response.statusCode).toEqual(200); + expect(response.headers.etag).toBe( + /* cspell:disable-next-line */ + '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"', + ); + }); + + it('should return the "200" code for the "GET" request with the valid range header and wrong "If-Range" header', async () => { + const response = await req + .get("/bundle.js") + .set("Range", "bytes=3000-3500"); + + expect(response.statusCode).toEqual(206); + expect(response.headers["content-length"]).toEqual("501"); + expect(response.headers["content-type"]).toEqual( + "application/javascript; charset=utf-8", + ); + expect(response.text.length).toBe(501); + expect(response.headers.etag).toBeDefined(); + + const response1 = await req + .get("/bundle.js") + .set("If-Range", '"test') + .set("Range", "bytes=3000-3500"); + + expect(response1.statusCode).toEqual(200); + }); }); describe("should work and generate strong etag without createReadStream", () => { @@ -4371,6 +4462,31 @@ describe.each([ ); }); }); + + describe("should work and without etag generation and `if-none-match` header", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and `if-none-match` header without etag', async () => { + const response = await req + .get(`/bundle.js`) + .set("if-none-match", "etag"); + + expect(response.statusCode).toEqual(200); + expect(response.headers.etag).toBeUndefined(); + }); + }); }); describe("lastModified", () => { @@ -4392,13 +4508,6 @@ describe.each([ await close(server, instance); }); - function parseHttpDate(date) { - const timestamp = date && Date.parse(date); - - // istanbul ignore next: guard against date.js Date.parse patching - return typeof timestamp === "number" ? timestamp : NaN; - } - it('should return the "200" code for the "GET" request to the bundle file and set "Last-Modified"', async () => { const response = await req.get(`/bundle.js`); @@ -4406,7 +4515,7 @@ describe.each([ expect(response.headers["last-modified"]).toBeDefined(); }); - it('should return the "304" code for the "GET" request to the bundle file with "Last-Modified" and "if-unmodified-since" header', async () => { + it('should return the "304" code for the "GET" request to the bundle file with "Last-Modified" and "if-modified-since" header', async () => { const response1 = await req.get(`/bundle.js`); expect(response1.statusCode).toEqual(200); @@ -4414,19 +4523,25 @@ describe.each([ const response2 = await req .get(`/bundle.js`) - .set("if-unmodified-since", response1.headers["last-modified"]); + .set("if-modified-since", response1.headers["last-modified"]); expect(response2.statusCode).toEqual(304); expect(response2.headers["last-modified"]).toBeDefined(); const response3 = await req .get(`/bundle.js`) - .set("if-unmodified-since", "Fri, 29 Mar 2020 10:25:50 GMT"); + .set( + "if-modified-since", + new Date( + parseHttpDate(response1.headers["last-modified"]) - 1000, + ).toUTCString(), + ); - expect(response3.statusCode).toEqual(412); + expect(response3.statusCode).toEqual(200); + expect(response3.headers["last-modified"]).toBeDefined(); }); - it('should return the "304" code for the "GET" request to the bundle file with "Last-Modified" and "if-modified-since" header', async () => { + it('should return the "200" code for the "GET" request to the bundle file with "Last-Modified" and "if-unmodified-since" header', async () => { const response1 = await req.get(`/bundle.js`); expect(response1.statusCode).toEqual(200); @@ -4434,22 +4549,16 @@ describe.each([ const response2 = await req .get(`/bundle.js`) - .set("if-modified-since", response1.headers["last-modified"]); + .set("if-unmodified-since", response1.headers["last-modified"]); - expect(response2.statusCode).toEqual(304); + expect(response2.statusCode).toEqual(200); expect(response2.headers["last-modified"]).toBeDefined(); const response3 = await req .get(`/bundle.js`) - .set( - "if-modified-since", - new Date( - parseHttpDate(response1.headers["last-modified"]) - 1000, - ).toUTCString(), - ); + .set("if-unmodified-since", "Fri, 29 Mar 2020 10:25:50 GMT"); - expect(response3.statusCode).toEqual(200); - expect(response3.headers["last-modified"]).toBeDefined(); + expect(response3.statusCode).toEqual(412); }); it('should return the "412" code for the "GET" request to the bundle file with etag and "if-unmodified-since" header', async () => { @@ -4484,6 +4593,19 @@ describe.each([ expect(response2.statusCode).toEqual(200); expect(response1.headers["last-modified"]).toBeDefined(); }); + + it('should return the "200" code for the "GET" request with the valid range header and old "if-range" header', async () => { + const response = await req + .get("/bundle.js") + .set("if-range", new Date(1000).toUTCString()) + .set("Range", "bytes=3000-3500"); + + expect(response.statusCode).toEqual(200); + expect(response.headers["content-type"]).toEqual( + "application/javascript; charset=utf-8", + ); + expect(response.headers["last-modified"]).toBeDefined(); + }); }); describe('should work and prefer "if-match" and "if-none-match"', () => { @@ -4505,13 +4627,6 @@ describe.each([ await close(server, instance); }); - function parseHttpDate(date) { - const timestamp = date && Date.parse(date); - - // istanbul ignore next: guard against date.js Date.parse patching - return typeof timestamp === "number" ? timestamp : NaN; - } - it('should return the "304" code for the "GET" request to the bundle file and prefer "if-match" over "if-unmodified-since"', async () => { const response1 = await req.get(`/bundle.js`); @@ -4529,7 +4644,7 @@ describe.each([ ).toUTCString(), ); - expect(response2.statusCode).toEqual(304); + expect(response2.statusCode).toEqual(200); expect(response2.headers["last-modified"]).toBeDefined(); expect(response2.headers.etag).toBeDefined(); }); diff --git a/types/index.d.ts b/types/index.d.ts index 1080632b0..ac7d038ca 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -38,7 +38,7 @@ export = wdm; */ /** * @typedef {Object} ResponseData - * @property {string | Buffer | ReadStream} data + * @property {Buffer | ReadStream} data * @property {number} byteLength */ /** @@ -47,7 +47,7 @@ export = wdm; * @callback ModifyResponseData * @param {RequestInternal} req * @param {ResponseInternal} res - * @param {string | Buffer | ReadStream} data + * @param {Buffer | ReadStream} data * @param {number} byteLength * @return {ResponseData} */ @@ -284,7 +284,7 @@ type Callback = ( stats?: import("webpack").Stats | import("webpack").MultiStats | undefined, ) => any; type ResponseData = { - data: string | Buffer | ReadStream; + data: Buffer | ReadStream; byteLength: number; }; type ModifyResponseData< @@ -293,7 +293,7 @@ type ModifyResponseData< > = ( req: RequestInternal, res: ResponseInternal, - data: string | Buffer | ReadStream, + data: Buffer | ReadStream, byteLength: number, ) => ResponseData; type Context< diff --git a/types/utils/compatibleAPI.d.ts b/types/utils/compatibleAPI.d.ts index 369054f7e..0998c346d 100644 --- a/types/utils/compatibleAPI.d.ts +++ b/types/utils/compatibleAPI.d.ts @@ -45,3 +45,19 @@ export function pipe( res: Response & ExpectedResponse, bufferOrStream: import("fs").ReadStream, ): void; +/** + * @param {string} filename + * @param {import("../index").OutputFileSystem} outputFileSystem + * @param {number} start + * @param {number} end + * @returns {{ bufferOrStream: (Buffer | import("fs").ReadStream), byteLength: number }} + */ +export function createReadStreamOrReadFileSync( + filename: string, + outputFileSystem: import("../index").OutputFileSystem, + start: number, + end: number, +): { + bufferOrStream: Buffer | import("fs").ReadStream; + byteLength: number; +}; diff --git a/types/utils/memorize.d.ts b/types/utils/memorize.d.ts new file mode 100644 index 000000000..6313160a0 --- /dev/null +++ b/types/utils/memorize.d.ts @@ -0,0 +1,26 @@ +export = memorize; +/** + * @template T + * @param {Function} fn + * @param {{ cache?: Map } | undefined} cache + * @param {((value: T) => T)=} callback + * @returns {any} + */ +declare function memorize( + fn: Function, + { + cache, + }?: + | { + cache?: + | Map< + string, + { + data: T; + } + > + | undefined; + } + | undefined, + callback?: ((value: T) => T) | undefined, +): any;