@hint/hint-http-compression
Version:
hint for HTTP compression related best practices
330 lines (329 loc) • 18.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const util_1 = require("util");
const zlib = require("zlib");
const utils_1 = require("@hint/utils");
const utils_string_1 = require("@hint/utils-string");
const utils_network_1 = require("@hint/utils-network");
const utils_types_1 = require("@hint/utils-types");
const meta_1 = require("./meta");
const i18n_import_1 = require("./i18n.import");
const decompressBrotli = (0, util_1.promisify)(zlib.brotliDecompress);
const uaString = 'Mozilla/5.0 Gecko';
class HttpCompressionHint {
constructor(context) {
const getHintOptions = (property) => {
return Object.assign({ brotli: true, gzip: true, threshold: 1024, zopfli: true }, (context.hintOptions && context.hintOptions[property]));
};
const resourceOptions = getHintOptions('resource');
const htmlOptions = getHintOptions('html');
const isBigFile = (size, options) => {
return size > options.threshold;
};
const checkIfBytesMatch = (rawResponse, magicNumbers) => {
return rawResponse && magicNumbers.every((b, i) => {
return rawResponse[i] === b;
});
};
const getHeaderValues = (headers, headerName) => {
return ((0, utils_network_1.normalizeHeaderValue)(headers, headerName) || '').split(',');
};
const checkVaryHeader = (resource, headers) => {
const varyHeaderValues = getHeaderValues(headers, 'vary');
const cacheControlValues = getHeaderValues(headers, 'cache-control');
if (!cacheControlValues.includes('private') &&
!varyHeaderValues.includes('accept-encoding')) {
let codeSnippet = '';
if (varyHeaderValues.length > 0) {
codeSnippet = `Vary: ${varyHeaderValues.join(',')}\n`;
}
if (cacheControlValues.length > 0) {
codeSnippet += `Cache-Control: ${cacheControlValues.join(',')}`;
}
context.report(resource, (0, i18n_import_1.getMessage)('responseShouldIncludeVary', context.language), {
codeLanguage: 'http',
codeSnippet: codeSnippet.trim(),
severity: utils_types_1.Severity.warning
});
}
};
const generateDisallowedCompressionMessage = (encoding) => {
return (0, i18n_import_1.getMessage)('responseShouldNotBeCompressed', context.language, encoding);
};
const generateContentEncodingMessage = (encoding) => {
return (0, i18n_import_1.getMessage)('responseShouldIncludeContentEncoding', context.language, encoding);
};
const generateGzipCompressionMessage = (encoding) => {
return (0, i18n_import_1.getMessage)('responseShouldBeCompressedGzip', context.language, encoding);
};
const generateSizeMessage = (resource, element, encoding, sizeDifference) => {
if (sizeDifference > 0) {
context.report(resource, (0, i18n_import_1.getMessage)('responseBiggerThan', context.language, encoding), { element, severity: utils_types_1.Severity.warning });
}
else {
context.report(resource, (0, i18n_import_1.getMessage)('responseSameSize', context.language, encoding), { element, severity: utils_types_1.Severity.hint });
}
};
const getNetworkData = async (resource, requestHeaders) => {
const safeFetch = (0, utils_1.asyncTry)(context.fetchContent.bind(context));
const networkData = await safeFetch(resource, requestHeaders);
if (!networkData) {
return null;
}
const safeRawResponse = (0, utils_1.asyncTry)(networkData.response.body.rawResponse.bind(networkData.response.body));
const rawResponse = await safeRawResponse();
if (!rawResponse) {
return null;
}
return {
contentEncodingHeaderValue: (0, utils_network_1.normalizeHeaderValue)(networkData.response.headers, 'content-encoding'),
rawContent: networkData.response.body.rawContent,
rawResponse,
response: networkData.response
};
};
const isCompressedWithBrotli = async (rawResponse) => {
try {
const decompressedContent = await decompressBrotli(rawResponse);
if (decompressedContent.byteLength === 0 &&
rawResponse.byteLength !== 0) {
return false;
}
}
catch (e) {
return false;
}
return true;
};
const isCompressedWithGzip = (rawContent) => {
return checkIfBytesMatch(rawContent, [0x1f, 0x8b]);
};
const isNotCompressedWithZopfli = (rawResponse) => {
return !checkIfBytesMatch(rawResponse, [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x03]);
};
const checkBrotli = async (resource, element, options) => {
let networkData = await getNetworkData(resource, { 'Accept-Encoding': 'br' });
if (!networkData) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetchedBrotli', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
const { contentEncodingHeaderValue, rawResponse, response } = networkData;
const compressedWithBrotli = await isCompressedWithBrotli(rawResponse);
if ((0, utils_network_1.isHTTP)(resource)) {
if (compressedWithBrotli) {
context.report(resource, (0, i18n_import_1.getMessage)('noCompressedBrotliOverHTTP', context.language), { element, severity: utils_types_1.Severity.warning });
}
return;
}
const rawContent = compressedWithBrotli ? await decompressBrotli(rawResponse) : response.body.rawContent;
const itShouldNotBeCompressed = contentEncodingHeaderValue === 'br' &&
rawContent.byteLength <= rawResponse.byteLength;
if (compressedWithBrotli && itShouldNotBeCompressed) {
generateSizeMessage(resource, element, 'Brotli', rawResponse.byteLength - rawContent.byteLength);
return;
}
if (!compressedWithBrotli) {
context.report(resource, (0, i18n_import_1.getMessage)('compressedWithBrotliOverHTTPS', context.language), { element, severity: isBigFile(rawResponse.byteLength, options) ? utils_types_1.Severity.warning : utils_types_1.Severity.hint });
return;
}
checkVaryHeader(resource, response.headers);
if (contentEncodingHeaderValue !== 'br') {
context.report(resource, generateContentEncodingMessage('br'), { element, severity: utils_types_1.Severity.error });
}
networkData = await getNetworkData(resource, {
'Accept-Encoding': 'br',
'User-Agent': uaString
});
if (!networkData) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetchedBrotli', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
const { rawResponse: uaRawResponse } = networkData;
if (!(await isCompressedWithBrotli(uaRawResponse))) {
context.report(resource, (0, i18n_import_1.getMessage)('compressedWithBrotliOverHTTPSAgent', context.language), { element, severity: utils_types_1.Severity.warning });
}
};
const checkGzipZopfli = async (resource, element, shouldCheckIfCompressedWith) => {
let networkData = await getNetworkData(resource, { 'Accept-Encoding': 'gzip' });
if (!networkData) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetchedGzip', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
const { contentEncodingHeaderValue, rawContent, rawResponse, response } = networkData;
const compressedWithGzip = isCompressedWithGzip(rawResponse);
const notCompressedWithZopfli = isNotCompressedWithZopfli(rawResponse);
const itShouldNotBeCompressed = contentEncodingHeaderValue === 'gzip' &&
rawContent.byteLength <= rawResponse.byteLength;
if (compressedWithGzip && itShouldNotBeCompressed) {
generateSizeMessage(resource, element, notCompressedWithZopfli ? 'gzip' : 'Zopfli', rawResponse.byteLength - rawContent.byteLength);
return;
}
if (!compressedWithGzip && shouldCheckIfCompressedWith.gzip) {
context.report(resource, generateGzipCompressionMessage('gzip'), { element, severity: isBigFile(rawResponse.byteLength, shouldCheckIfCompressedWith) ? utils_types_1.Severity.error : utils_types_1.Severity.hint });
return;
}
if (notCompressedWithZopfli && shouldCheckIfCompressedWith.zopfli) {
context.report(resource, generateGzipCompressionMessage('Zopfli'), { element, severity: utils_types_1.Severity.hint });
}
if (shouldCheckIfCompressedWith.gzip ||
shouldCheckIfCompressedWith.zopfli) {
checkVaryHeader(resource, response.headers);
if (contentEncodingHeaderValue !== 'gzip') {
context.report(resource, generateContentEncodingMessage('gzip'), { element, severity: utils_types_1.Severity.error });
}
}
networkData = await getNetworkData(resource, {
'Accept-Encoding': 'gzip',
'User-Agent': uaString
});
if (!networkData) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetchedGzip', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
const { rawResponse: uaRawResponse } = networkData;
if (!isCompressedWithGzip(uaRawResponse) &&
shouldCheckIfCompressedWith.gzip) {
context.report(resource, (0, i18n_import_1.getMessage)('compressedWithGzipAgent', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
if (isNotCompressedWithZopfli(uaRawResponse) &&
!notCompressedWithZopfli &&
shouldCheckIfCompressedWith.zopfli) {
context.report(resource, (0, i18n_import_1.getMessage)('compressedWithZopfliAgent', context.language), { element, severity: utils_types_1.Severity.hint });
}
};
const responseIsCompressed = async (rawResponse, contentEncodingHeaderValue) => {
return isCompressedWithGzip(rawResponse) ||
await isCompressedWithBrotli(rawResponse) ||
(!!contentEncodingHeaderValue &&
(contentEncodingHeaderValue !== 'identity'));
};
const checkForDisallowedCompressionMethods = async (resource, element, response) => {
const contentEncodingHeaderValue = (0, utils_network_1.normalizeHeaderValue)(response.headers, 'content-encoding');
if (!contentEncodingHeaderValue) {
return;
}
const encodings = contentEncodingHeaderValue.split(',');
for (const encoding of encodings) {
if (!['gzip', 'br'].includes(encoding)) {
const safeRawResponse = (0, utils_1.asyncTry)(response.body.rawResponse.bind(response.body));
const rawResponse = await safeRawResponse();
if (!rawResponse) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetched', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
if (encoding === 'x-gzip' && isCompressedWithGzip(rawResponse)) {
return;
}
context.report(resource, generateDisallowedCompressionMessage(encoding), { element, severity: utils_types_1.Severity.warning });
}
}
if ((0, utils_string_1.normalizeString)(response.headers['get-dictionary'])) {
context.report(resource, generateDisallowedCompressionMessage('sdch'), { element, severity: utils_types_1.Severity.warning });
}
};
const checkUncompressed = async (resource, element) => {
const networkData = await getNetworkData(resource, { 'Accept-Encoding': 'identity' });
if (!networkData) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetchedUncompressed', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
const { contentEncodingHeaderValue, rawResponse } = networkData;
if (await responseIsCompressed(rawResponse, contentEncodingHeaderValue)) {
context.report(resource, (0, i18n_import_1.getMessage)('shouldNotBeCompressedWithIdentity', context.language), { element, severity: utils_types_1.Severity.warning });
}
if (contentEncodingHeaderValue) {
context.report(resource, (0, i18n_import_1.getMessage)('shouldNotIncludeWithIdentity', context.language), { element, severity: utils_types_1.Severity.warning });
}
};
const isCompressibleAccordingToMediaType = (mediaType) => {
if (!mediaType) {
return false;
}
const OTHER_COMMON_MEDIA_TYPES_THAT_SHOULD_BE_COMPRESSED = [
'application/rtf',
'application/wasm',
'font/collection',
'font/eot',
'font/otf',
'font/sfnt',
'font/ttf',
'image/bmp',
'image/vnd.microsoft.icon',
'image/x-icon',
'x-shader/x-fragment',
'x-shader/x-vertex'
];
if ((0, utils_1.isTextMediaType)(mediaType) ||
OTHER_COMMON_MEDIA_TYPES_THAT_SHOULD_BE_COMPRESSED.includes(mediaType)) {
return true;
}
return false;
};
const isSpecialCase = async (resource, element, response) => {
const safeRawResponse = (0, utils_1.asyncTry)(response.body.rawResponse.bind(response.body));
const rawResponse = await safeRawResponse();
if (!rawResponse) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetched', context.language), { element, severity: utils_types_1.Severity.error });
return false;
}
if ((response.mediaType === 'image/svg+xml' || (0, utils_1.getFileExtension)(resource) === 'svgz') &&
isCompressedWithGzip(rawResponse)) {
const headerValue = (0, utils_network_1.normalizeHeaderValue)(response.headers, 'content-encoding');
if (headerValue !== 'gzip') {
context.report(resource, generateContentEncodingMessage('gzip'), {
codeLanguage: 'http',
codeSnippet: `Content-Encoding: ${headerValue}`,
severity: utils_types_1.Severity.error
});
}
return true;
}
return false;
};
const validate = async ({ element, resource, response }, eventName) => {
const shouldCheckIfCompressedWith = eventName === 'fetch::end::html' ? htmlOptions : resourceOptions;
if (response.statusCode !== 200) {
return;
}
if (!(0, utils_network_1.isRegularProtocol)(resource)) {
return;
}
if (await isSpecialCase(resource, element, response)) {
return;
}
if (!isCompressibleAccordingToMediaType(response.mediaType)) {
const safeRawResponse = (0, utils_1.asyncTry)(response.body.rawResponse.bind(response.body));
const rawResponse = await safeRawResponse();
if (!rawResponse) {
context.report(resource, (0, i18n_import_1.getMessage)('couldNotBeFetched', context.language), { element, severity: utils_types_1.Severity.error });
return;
}
const contentEncodingHeaderValue = (0, utils_network_1.normalizeHeaderValue)(response.headers, 'content-encoding');
if (await responseIsCompressed(rawResponse, contentEncodingHeaderValue)) {
context.report(resource, (0, i18n_import_1.getMessage)('shouldNotBeCompressed', context.language), { element, severity: utils_types_1.Severity.warning });
}
if (contentEncodingHeaderValue) {
context.report(resource, (0, i18n_import_1.getMessage)('shouldNotIncludeContentEncoding', context.language), {
codeLanguage: 'http',
codeSnippet: `Content-Encoding: ${contentEncodingHeaderValue}`,
severity: utils_types_1.Severity.warning
});
}
return;
}
await checkForDisallowedCompressionMethods(resource, element, response);
await checkUncompressed(resource, element);
if (shouldCheckIfCompressedWith.gzip ||
shouldCheckIfCompressedWith.zopfli) {
await checkGzipZopfli(resource, element, shouldCheckIfCompressedWith);
}
if (shouldCheckIfCompressedWith.brotli) {
await checkBrotli(resource, element, shouldCheckIfCompressedWith);
}
};
context.on('fetch::end::*', validate);
}
}
exports.default = HttpCompressionHint;
HttpCompressionHint.meta = meta_1.default;