From 82e27d204822c49204530cfe1d68adc19f3f86a5 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Wed, 4 Oct 2023 21:00:55 -0400 Subject: [PATCH] Updating dependencies, adding `.github` directory, WIP --- dist/tiny-etag.cjs | 87 +++++++++++++++++++++++++++++----------------- dist/tiny-etag.js | 83 ++++++++++++++++++++++++++----------------- package-lock.json | 8 ++--- package.json | 2 +- src/constants.js | 25 +++++++++++++ src/etag.js | 83 ++++++++++++++++++++++--------------------- src/utils.js | 16 +++++++++ test/etag.js | 36 +++++++++---------- 8 files changed, 213 insertions(+), 127 deletions(-) create mode 100644 src/constants.js create mode 100644 src/utils.js diff --git a/dist/tiny-etag.cjs b/dist/tiny-etag.cjs index 639ed54..5fd550f 100644 --- a/dist/tiny-etag.cjs +++ b/dist/tiny-etag.cjs @@ -7,60 +7,86 @@ */ 'use strict'; -var node_url = require('node:url'); var tinyLru = require('tiny-lru'); -var MurmurHash3 = require('murmurhash3js'); - -const mmh3 = MurmurHash3.x86.hash32; - -function clone (arg) { - return JSON.parse(JSON.stringify(arg, null, 0)); +var node_crypto = require('node:crypto'); + +const BASE64 = "base64"; +const SHA1 = "sha1"; +const CACHE_CONTROL = "cache-control"; +const CONTENT_LOCATION = "content-location"; +const DATE = "date"; +const ETAG = "etag"; +const EXPIRES = "expires"; +const VARY = "vary"; +const TEXT_PLAIN = "text/plain"; + +const INT_DETAULT_CACHE = 1e3; +const INT_0 = 0; +const INT_200 = 200; +const INT_304 = 304; +const INT_1000 = 1000; + +const GET = "GET"; +const FINISH = "finish"; +const RANGE = "range"; +const IF_NONE_MATCH = "if-none-match"; +const EMPTY = ""; +const NO_CACHE = "no-cache"; +const NO_STORE = "no-store"; + +const clone = typeof structuredClone === "function" ? structuredClone : arg => JSON.parse(JSON.stringify(arg)); + +function hash (arg = "") { + return node_crypto.createHash(SHA1).update(arg).digest(BASE64); } function keep (arg) { - return arg === "cache-control" || arg === "content-location" || arg === "date" || arg === "etag" || arg === "expires" || arg === "vary"; + return arg === CACHE_CONTROL || arg === CONTENT_LOCATION || arg === DATE || arg === ETAG || arg === EXPIRES || arg === VARY; } function parse (arg) { - return new node_url.URL(typeof arg === "string" ? arg : `http://${arg.headers.host || `localhost:${arg.socket.server._connectionKey.replace(/.*::/, "")}`}${arg.url}`); + return new URL(typeof arg === "string" ? arg : `http://${arg.headers.host || `localhost:${arg.socket.server._connectionKey.replace(/.*::/, "")}`}${arg.url}`); } class ETag { - constructor (cacheSize, cacheTTL, seed, mimetype) { + constructor (cacheSize, cacheTTL, mimetype) { this.cache = tinyLru.lru(cacheSize, cacheTTL); this.mimetype = mimetype; - this.seed = seed; } - create (arg) { - return `"${mmh3(arg, this.seed)}"`; + create (arg = EMPTY, mimetype = this.mimetype) { + return `"${this.hash(arg, mimetype)}"`; + } + + hash (arg = EMPTY, mimetype = this.mimetype) { + return hash(`${arg}_${mimetype}`); } middleware (req, res, next) { - if (req.method === "GET") { - const uri = (req.parsed || parse(req)).href, + if (req.method === GET) { + const uri = (req.parsed || this.parse(req)).href, key = this.hash(uri, req.headers.accept), cached = this.cache.get(key); - res.on("finish", () => { + res.on(FINISH, () => { const headers = res.getHeaders(), status = res.statusCode; - if ((status === 200 || status === 304) && "etag" in headers && this.valid(headers)) { + if ((status === INT_200 || status === INT_304) && ETAG in headers && this.valid(headers)) { this.register(key, { etag: headers.etag, headers: headers, - timestamp: cached ? cached.timestamp : Math.floor(new Date().getTime() / 1000) + timestamp: cached ? cached.timestamp : Math.floor(Date.now() / INT_1000) }); } }); - if (cached !== void 0 && "etag" in cached && "range" in req.headers === false && req.headers["if-none-match"] === cached.etag) { + if (cached !== void 0 && ETAG in cached && RANGE in req.headers === false && req.headers[IF_NONE_MATCH] === cached.etag) { const headers = clone(cached.headers); - headers.age = Math.floor(new Date().getTime() / 1000) - cached.timestamp; - res.removeHeader("cache-control"); - res.send("", 304, headers); + headers.age = Math.floor(Date.now() / INT_1000) - cached.timestamp; + res.removeHeader(CACHE_CONTROL); + res.send(EMPTY, INT_304, headers); } else { next(); } @@ -69,8 +95,8 @@ class ETag { } } - hash (arg = "", mimetype = "") { - return this.create(`${arg}_${mimetype || this.mimetype}`); + parse (arg) { + return parse(arg); } register (key, arg) { @@ -87,23 +113,20 @@ class ETag { return this; } - unregister (key) { - this.cache.delete(key); - } - valid (headers) { - const header = headers["cache-control"] || ""; + const header = headers[CACHE_CONTROL] || EMPTY; - return header.length === 0 || (header.includes("no-cache") === false && header.includes("no-store") === false); // eslint-disable-line no-extra-parens + return (header.includes(NO_CACHE) === false && header.includes(NO_STORE) === false) || header.length === INT_0; // eslint-disable-line no-extra-parens } } -function etag ({cacheSize = 1e3, cacheTTL = 0, seed = null, mimetype = "text/plain"} = {}) { - const obj = new ETag(cacheSize, cacheTTL, seed !== null ? seed : Math.floor(Math.random() * cacheSize) + 1, mimetype); +function etag ({cacheSize = INT_DETAULT_CACHE, cacheTTL = INT_0, mimetype = TEXT_PLAIN} = {}) { + const obj = new ETag(cacheSize, cacheTTL, mimetype); obj.middleware = obj.middleware.bind(obj); return obj; } +exports.ETag = ETag; exports.etag = etag; diff --git a/dist/tiny-etag.js b/dist/tiny-etag.js index 3ef654a..fb40d8d 100644 --- a/dist/tiny-etag.js +++ b/dist/tiny-etag.js @@ -5,56 +5,79 @@ * @license BSD-3-Clause * @version 4.0.0 */ -import {URL}from'node:url';import {lru}from'tiny-lru';import MurmurHash3 from'murmurhash3js';const mmh3 = MurmurHash3.x86.hash32; - -function clone (arg) { - return JSON.parse(JSON.stringify(arg, null, 0)); +import {lru}from'tiny-lru';import {createHash}from'node:crypto';const BASE64 = "base64"; +const SHA1 = "sha1"; +const CACHE_CONTROL = "cache-control"; +const CONTENT_LOCATION = "content-location"; +const DATE = "date"; +const ETAG = "etag"; +const EXPIRES = "expires"; +const VARY = "vary"; +const TEXT_PLAIN = "text/plain"; + +const INT_DETAULT_CACHE = 1e3; +const INT_0 = 0; +const INT_200 = 200; +const INT_304 = 304; +const INT_1000 = 1000; + +const GET = "GET"; +const FINISH = "finish"; +const RANGE = "range"; +const IF_NONE_MATCH = "if-none-match"; +const EMPTY = ""; +const NO_CACHE = "no-cache"; +const NO_STORE = "no-store";const clone = typeof structuredClone === "function" ? structuredClone : arg => JSON.parse(JSON.stringify(arg)); + +function hash (arg = "") { + return createHash(SHA1).update(arg).digest(BASE64); } function keep (arg) { - return arg === "cache-control" || arg === "content-location" || arg === "date" || arg === "etag" || arg === "expires" || arg === "vary"; + return arg === CACHE_CONTROL || arg === CONTENT_LOCATION || arg === DATE || arg === ETAG || arg === EXPIRES || arg === VARY; } function parse (arg) { return new URL(typeof arg === "string" ? arg : `http://${arg.headers.host || `localhost:${arg.socket.server._connectionKey.replace(/.*::/, "")}`}${arg.url}`); -} - -class ETag { - constructor (cacheSize, cacheTTL, seed, mimetype) { +}class ETag { + constructor (cacheSize, cacheTTL, mimetype) { this.cache = lru(cacheSize, cacheTTL); this.mimetype = mimetype; - this.seed = seed; } - create (arg) { - return `"${mmh3(arg, this.seed)}"`; + create (arg = EMPTY, mimetype = this.mimetype) { + return `"${this.hash(arg, mimetype)}"`; + } + + hash (arg = EMPTY, mimetype = this.mimetype) { + return hash(`${arg}_${mimetype}`); } middleware (req, res, next) { - if (req.method === "GET") { - const uri = (req.parsed || parse(req)).href, + if (req.method === GET) { + const uri = (req.parsed || this.parse(req)).href, key = this.hash(uri, req.headers.accept), cached = this.cache.get(key); - res.on("finish", () => { + res.on(FINISH, () => { const headers = res.getHeaders(), status = res.statusCode; - if ((status === 200 || status === 304) && "etag" in headers && this.valid(headers)) { + if ((status === INT_200 || status === INT_304) && ETAG in headers && this.valid(headers)) { this.register(key, { etag: headers.etag, headers: headers, - timestamp: cached ? cached.timestamp : Math.floor(new Date().getTime() / 1000) + timestamp: cached ? cached.timestamp : Math.floor(Date.now() / INT_1000) }); } }); - if (cached !== void 0 && "etag" in cached && "range" in req.headers === false && req.headers["if-none-match"] === cached.etag) { + if (cached !== void 0 && ETAG in cached && RANGE in req.headers === false && req.headers[IF_NONE_MATCH] === cached.etag) { const headers = clone(cached.headers); - headers.age = Math.floor(new Date().getTime() / 1000) - cached.timestamp; - res.removeHeader("cache-control"); - res.send("", 304, headers); + headers.age = Math.floor(Date.now() / INT_1000) - cached.timestamp; + res.removeHeader(CACHE_CONTROL); + res.send(EMPTY, INT_304, headers); } else { next(); } @@ -63,8 +86,8 @@ class ETag { } } - hash (arg = "", mimetype = "") { - return this.create(`${arg}_${mimetype || this.mimetype}`); + parse (arg) { + return parse(arg); } register (key, arg) { @@ -81,21 +104,17 @@ class ETag { return this; } - unregister (key) { - this.cache.delete(key); - } - valid (headers) { - const header = headers["cache-control"] || ""; + const header = headers[CACHE_CONTROL] || EMPTY; - return header.length === 0 || (header.includes("no-cache") === false && header.includes("no-store") === false); // eslint-disable-line no-extra-parens + return (header.includes(NO_CACHE) === false && header.includes(NO_STORE) === false) || header.length === INT_0; // eslint-disable-line no-extra-parens } } -function etag ({cacheSize = 1e3, cacheTTL = 0, seed = null, mimetype = "text/plain"} = {}) { - const obj = new ETag(cacheSize, cacheTTL, seed !== null ? seed : Math.floor(Math.random() * cacheSize) + 1, mimetype); +function etag ({cacheSize = INT_DETAULT_CACHE, cacheTTL = INT_0, mimetype = TEXT_PLAIN} = {}) { + const obj = new ETag(cacheSize, cacheTTL, mimetype); obj.middleware = obj.middleware.bind(obj); return obj; -}export{etag}; \ No newline at end of file +}export{ETag,etag}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 09b5484..577401a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "mocha": "^10.2.0", "nyc": "^15.1.0", "rollup": "^3.29.2", - "tiny-httptest": "^4.0.4", + "tiny-httptest": "^4.0.5", "typescript": "^5.2.2", "woodland": "^17.0.2" }, @@ -3260,9 +3260,9 @@ } }, "node_modules/tiny-httptest": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tiny-httptest/-/tiny-httptest-4.0.4.tgz", - "integrity": "sha512-B1c01hJW98AQ7xvtal6sYr8AfS0ZK3ugD/SyCpaNUTUE9xd0IyVjTEzIOd6Qpcarm2XLLYfvr5HE/rNe4O30Ng==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/tiny-httptest/-/tiny-httptest-4.0.5.tgz", + "integrity": "sha512-7JYuaJkFX7Vq+wM61/AQRfTiSy0YKRA29ZhiG8w1gN9rRK+CqCG5rqzI+6IHcUkOAfkPBGzrtrnNr++4A2HpZg==", "dev": true, "dependencies": { "btoa": "^1.2.1", diff --git a/package.json b/package.json index b6e7b26..b224cda 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "mocha": "^10.2.0", "nyc": "^15.1.0", "rollup": "^3.29.2", - "tiny-httptest": "^4.0.4", + "tiny-httptest": "^4.0.5", "typescript": "^5.2.2", "woodland": "^17.0.2" } diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..db271bd --- /dev/null +++ b/src/constants.js @@ -0,0 +1,25 @@ +export const BASE64 = "base64"; +export const SHA1 = "sha1"; +export const CACHE_CONTROL = "cache-control"; +export const CONTENT_LOCATION = "content-location"; +export const DATE = "date"; +export const ETAG = "etag"; +export const EXPIRES = "expires"; +export const VARY = "vary"; +export const TEXT_PLAIN = "text/plain"; + +export const INT_DETAULT_CACHE = 1e3; +export const INT_0 = 0; +export const INT_200 = 200; +export const INT_304 = 304; +export const INT_1000 = 1000; + +export const GET = "GET"; +export const FINISH = "finish"; +export const RANGE = "range"; +export const IF_NONE_MATCH = "if-none-match"; +export const EMPTY = ""; +export const NO_CACHE = "no-cache"; +export const NO_STORE = "no-store"; +export const PUBLIC = "public"; +export const CONTENT_TYPE = "content-type"; diff --git a/src/etag.js b/src/etag.js index 262eac7..2cc6f79 100644 --- a/src/etag.js +++ b/src/etag.js @@ -1,56 +1,63 @@ -import {URL} from "node:url"; import {lru} from "tiny-lru"; -import MurmurHash3 from "murmurhash3js"; -const mmh3 = MurmurHash3.x86.hash32; - -function clone (arg) { - return JSON.parse(JSON.stringify(arg, null, 0)); -} - -function keep (arg) { - return arg === "cache-control" || arg === "content-location" || arg === "date" || arg === "etag" || arg === "expires" || arg === "vary"; -} - -function parse (arg) { - return new URL(typeof arg === "string" ? arg : `http://${arg.headers.host || `localhost:${arg.socket.server._connectionKey.replace(/.*::/, "")}`}${arg.url}`); -} - -class ETag { - constructor (cacheSize, cacheTTL, seed, mimetype) { +import { + CACHE_CONTROL, + EMPTY, + ETAG, + FINISH, + GET, + IF_NONE_MATCH, + INT_0, + INT_1000, + INT_200, + INT_304, + INT_DETAULT_CACHE, + NO_CACHE, + NO_STORE, + RANGE, + TEXT_PLAIN +} from "./constants.js"; + +import {clone, hash, keep, parse} from "./utils.js"; + +export class ETag { + constructor (cacheSize, cacheTTL, mimetype) { this.cache = lru(cacheSize, cacheTTL); this.mimetype = mimetype; - this.seed = seed; } - create (arg) { - return `"${mmh3(arg, this.seed)}"`; + create (arg = EMPTY, mimetype = this.mimetype) { + return `"${this.hash(arg, mimetype)}"`; + } + + hash (arg = EMPTY, mimetype = this.mimetype) { + return hash(`${arg}_${mimetype}`); } middleware (req, res, next) { - if (req.method === "GET") { - const uri = (req.parsed || parse(req)).href, + if (req.method === GET) { + const uri = (req.parsed || this.parse(req)).href, key = this.hash(uri, req.headers.accept), cached = this.cache.get(key); - res.on("finish", () => { + res.on(FINISH, () => { const headers = res.getHeaders(), status = res.statusCode; - if ((status === 200 || status === 304) && "etag" in headers && this.valid(headers)) { + if ((status === INT_200 || status === INT_304) && ETAG in headers && this.valid(headers)) { this.register(key, { etag: headers.etag, headers: headers, - timestamp: cached ? cached.timestamp : Math.floor(new Date().getTime() / 1000) + timestamp: cached ? cached.timestamp : Math.floor(Date.now() / INT_1000) }); } }); - if (cached !== void 0 && "etag" in cached && "range" in req.headers === false && req.headers["if-none-match"] === cached.etag) { + if (cached !== void 0 && ETAG in cached && RANGE in req.headers === false && req.headers[IF_NONE_MATCH] === cached.etag) { const headers = clone(cached.headers); - headers.age = Math.floor(new Date().getTime() / 1000) - cached.timestamp; - res.removeHeader("cache-control"); - res.send("", 304, headers); + headers.age = Math.floor(Date.now() / INT_1000) - cached.timestamp; + res.removeHeader(CACHE_CONTROL); + res.send(EMPTY, INT_304, headers); } else { next(); } @@ -59,8 +66,8 @@ class ETag { } } - hash (arg = "", mimetype = "") { - return this.create(`${arg}_${mimetype || this.mimetype}`); + parse (arg) { + return parse(arg); } register (key, arg) { @@ -77,19 +84,15 @@ class ETag { return this; } - unregister (key) { - this.cache.delete(key); - } - valid (headers) { - const header = headers["cache-control"] || ""; + const header = headers[CACHE_CONTROL] || EMPTY; - return header.length === 0 || (header.includes("no-cache") === false && header.includes("no-store") === false); // eslint-disable-line no-extra-parens + return (header.includes(NO_CACHE) === false && header.includes(NO_STORE) === false) || header.length === INT_0; // eslint-disable-line no-extra-parens } } -export function etag ({cacheSize = 1e3, cacheTTL = 0, seed = null, mimetype = "text/plain"} = {}) { - const obj = new ETag(cacheSize, cacheTTL, seed !== null ? seed : Math.floor(Math.random() * cacheSize) + 1, mimetype); +export function etag ({cacheSize = INT_DETAULT_CACHE, cacheTTL = INT_0, mimetype = TEXT_PLAIN} = {}) { + const obj = new ETag(cacheSize, cacheTTL, mimetype); obj.middleware = obj.middleware.bind(obj); diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..eb87df8 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,16 @@ +import {createHash} from "node:crypto"; +import {BASE64, CACHE_CONTROL, CONTENT_LOCATION, DATE, ETAG, EXPIRES, SHA1, VARY} from "./constants.js"; + +export const clone = typeof structuredClone === "function" ? structuredClone : arg => JSON.parse(JSON.stringify(arg)); + +export function hash (arg = "") { + return createHash(SHA1).update(arg).digest(BASE64); +} + +export function keep (arg) { + return arg === CACHE_CONTROL || arg === CONTENT_LOCATION || arg === DATE || arg === ETAG || arg === EXPIRES || arg === VARY; +} + +export function parse (arg) { + return new URL(typeof arg === "string" ? arg : `http://${arg.headers.host || `localhost:${arg.socket.server._connectionKey.replace(/.*::/, "")}`}${arg.url}`); +} diff --git a/test/etag.js b/test/etag.js index d4a3b14..2fb5db0 100644 --- a/test/etag.js +++ b/test/etag.js @@ -1,30 +1,34 @@ import {createServer} from "node:http"; +import assert from "node:assert/strict"; import {httptest} from "tiny-httptest"; import {woodland} from "woodland"; -import MurmurHash3 from "murmurhash3js"; import {etag} from "../dist/tiny-etag.cjs"; -const mmh3 = MurmurHash3.x86.hash32; +import {CONTENT_TYPE, CACHE_CONTROL, INT_1000, INT_200, NO_CACHE, TEXT_PLAIN, PUBLIC} from "../src/constants.js"; -const random = Math.floor(Math.random() * 9) + 1, - cacheSize = 1000, - router = woodland({logging: {enabled: false}, defaultHeaders: {"Content-Type": "text/plain", "cache-control": "public"}, cacheSize: cacheSize}), - etagStore = etag({cacheSize: cacheSize, seed: random}), +const cacheSize = INT_1000, + router = woodland({logging: {enabled: false}, defaultHeaders: {[CONTENT_TYPE]: TEXT_PLAIN, [CACHE_CONTROL]: PUBLIC}, cacheSize: cacheSize}), + etagStore = etag({cacheSize: cacheSize}), msg = "Hello World!", - etagStoreValue = `"${mmh3(msg, random)}"`; + etagStoreValue = etagStore.create(msg); router.get(etagStore.middleware).ignore(etagStore.middleware); -router.get("/", (req, res) => res.send(msg, 200, {etag: etagStore.create(msg)})); -router.get("/no-cache", (req, res) => res.send(msg, 200, {"cache-control": "no-cache"})); +router.get("/", (req, res) => res.send(msg, INT_200, {etag: etagStore.create(msg)})); +router.get("/no-cache", (req, res) => res.send(msg, INT_200, {[CACHE_CONTROL]: NO_CACHE})); const server = createServer(router.route).listen(8001); +describe("Utility functions", function () { + it("Will parse valid URLs", function () { + assert.strictEqual(etagStore.parse("https://example.com/").href, "https://example.com/", "Should equal 'https://example.com/'"); + }); +}); + describe("Valid etagStore", function () { it("GET / (200 / 'Success')", function () { return httptest({url: "http://localhost:8001/"}) .expectStatus(200) .expectHeader("allow", "GET, HEAD, OPTIONS") .expectHeader("cache-control", "public") - .expectHeader("Content-Type", "text/plain") .expectHeader("etag", etagStoreValue) .expectBody(/^Hello World!$/) .end(); @@ -35,25 +39,24 @@ describe("Valid etagStore", function () { .expectStatus(200) .expectHeader("allow", "GET, HEAD, OPTIONS") .expectHeader("cache-control", "public") - .expectHeader("Content-Type", "text/plain") .expectHeader("etag", etagStoreValue) .expectBody(/^$/) .end(); }); it("GET / (200 / 'Success' / JSON)", function () { - return httptest({url: "http://localhost:8001/", headers: {accept: "application/json"}}) + return httptest({url: "http://localhost:8001/"}) .expectStatus(200) .expectHeader("allow", "GET, HEAD, OPTIONS") .expectHeader("cache-control", "public") - .expectHeader("Content-Type", "text/plain") + .expectHeader("content-type", "text/plain") .expectHeader("etag", etagStoreValue) .expectBody(/^Hello World!$/) .end(); }); it("GET / (304 / empty)", function () { - return httptest({url: "http://localhost:8001/", headers: {"If-None-Match": etagStoreValue}}) + return httptest({url: "http://localhost:8001/", headers: {"if-none-match": etagStoreValue}}) .expectStatus(304) .expectHeader("age", /\d+/) .expectHeader("content-length", void 0) @@ -64,7 +67,7 @@ describe("Valid etagStore", function () { }); it("GET / (304 / empty & validation)", function () { - return httptest({url: "http://localhost:8001/", headers: {"If-None-Match": etagStoreValue}}) + return httptest({url: "http://localhost:8001/", headers: {"if-none-match": etagStoreValue}}) .expectStatus(304) .expectHeader("age", /\d+/) .expectHeader("content-length", void 0) @@ -79,7 +82,6 @@ describe("Valid etagStore", function () { .expectStatus(200) .expectHeader("allow", "GET, HEAD, OPTIONS") .expectHeader("cache-control", "public") - .expectHeader("Content-Type", "text/plain") .expectHeader("etag", etagStoreValue) .expectBody(/^Hello World!$/) .end(); @@ -90,7 +92,6 @@ describe("Valid etagStore", function () { .expectStatus(200) .expectHeader("allow", "GET, HEAD, OPTIONS") .expectHeader("cache-control", "no-cache") - .expectHeader("Content-Type", "text/plain") .expectHeader("etag", void 0) .expectBody(/^Hello World!$/) .end(); @@ -101,7 +102,6 @@ describe("Valid etagStore", function () { .expectStatus(200) .expectHeader("allow", "GET, HEAD, OPTIONS") .expectHeader("cache-control", "no-cache") - .expectHeader("Content-Type", "text/plain") .expectHeader("etag", void 0) .expectBody(/^$/) .end().then(() => server.close());