@percy/agent
Version:
An agent process for integrating with Percy.
167 lines (166 loc) • 8.11 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const axios_1 = require("axios");
const crypto = require("crypto");
// @ts-ignore
const followRedirects = require("follow-redirects");
const fs = require("fs");
const os = require("os");
const path = require("path");
const url_1 = require("url");
const domain_match_1 = require("../utils/domain-match");
const logger_1 = require("../utils/logger");
const constants_1 = require("./constants");
const percy_client_service_1 = require("./percy-client-service");
const resource_service_1 = require("./resource-service");
const REDIRECT_STATUSES = [301, 302, 304, 307, 308];
const ALLOWED_RESPONSE_STATUSES = [200, 201, ...REDIRECT_STATUSES];
class ResponseService extends percy_client_service_1.default {
constructor(buildId, allowedHostnames, cacheResponses) {
super();
this.responsesProcessed = new Map();
this.resourceService = new resource_service_1.default(buildId);
this.allowedHostnames = allowedHostnames;
this.cacheResponses = cacheResponses;
}
shouldCaptureResource(rootUrl, resourceUrl) {
// Capture if the resourceUrl is the same as the rootUrL
if (resourceUrl.startsWith(rootUrl)) {
return true;
}
// Capture if the resourceUrl has a hostname in the allowedHostnames
if (this.allowedHostnames.some((hostname) => domain_match_1.default(hostname, resourceUrl))) {
return true;
}
// Resource is not allowed
return false;
}
async processResponse(rootResourceUrl, response, width, logger) {
logger.debug(logger_1.addLogDate(`processing response: ${response.url()} for width: ${width}`));
const url = this.parseRequestPath(response.url());
// skip responses already processed unless the response cache is disabled
const processResponse = this.responsesProcessed.get(url);
if (this.cacheResponses && processResponse) {
return processResponse;
}
const request = response.request();
const parsedRootResourceUrl = new url_1.URL(rootResourceUrl);
const isRedirect = REDIRECT_STATUSES.includes(response.status());
const rootUrl = `${parsedRootResourceUrl.protocol}//${parsedRootResourceUrl.hostname}`;
if (request.url() === rootResourceUrl) {
// Always skip the root resource
logger.debug(logger_1.addLogDate(`Skipping [is_root_resource]: ${request.url()}`));
return;
}
if (!ALLOWED_RESPONSE_STATUSES.includes(response.status())) {
// Only allow 2XX responses:
logger.debug(logger_1.addLogDate(`Skipping [disallowed_response_status_${response.status()}] [${width} px]: ${response.url()}`));
return;
}
if (!this.shouldCaptureResource(rootUrl, request.url())) {
// Disallow remote resource requests.
logger.debug(logger_1.addLogDate(`Skipping [is_remote_resource] [${width} px]: ${request.url()}`));
return;
}
if (isRedirect) {
// We don't want to follow too deep of a chain
// `followRedirects` is the npm package axios uses to follow redirected requests
// we'll use their max redirect setting as a guard here
if (request.redirectChain().length > followRedirects.maxRedirects) {
logger.debug(logger_1.addLogDate(`Skipping [redirect_too_deep: ${request.redirectChain().length}] [${width} px]: ${response.url()}`));
return;
}
const redirectedURL = `${rootUrl}${response.headers().location}`;
return this.handleRedirectResouce(url, redirectedURL, request.headers(), width, logger);
}
if (request.resourceType() === 'other' && (await response.text()).length === 0) {
// Skip empty other resource types (browser resource hints)
logger.debug(logger_1.addLogDate(`Skipping [is_empty_other]: ${request.url()}`));
return;
}
return this.handlePuppeteerResource(url, response, width, logger);
}
/**
* Handle processing and saving a resource that has a redirect chain. This
* will download the resource from node, and save the content as the orignal
* requesting url. This works since axios follows the redirect chain
* automatically.
*/
async handleRedirectResouce(originalURL, redirectedURL, requestHeaders, width, logger) {
logger.debug(logger_1.addLogDate(`Making local copy of redirected response: ${originalURL}`));
try {
const { data, headers } = await axios_1.default(originalURL, {
responseType: 'arraybuffer',
headers: requestHeaders,
});
const buffer = Buffer.from(data);
const sha = crypto.createHash('sha256').update(buffer).digest('hex');
const localCopy = path.join(os.tmpdir(), sha);
const didWriteFile = this.maybeWriteFile(localCopy, buffer);
const { fileIsTooLarge, responseBodySize } = this.checkFileSize(localCopy);
if (!didWriteFile) {
logger.debug(logger_1.addLogDate(`Skipping file copy [already_copied]: ${originalURL}`));
}
if (fileIsTooLarge) {
logger.debug(logger_1.addLogDate(`Skipping [max_file_size_exceeded_${responseBodySize}] [${width} px]: ${originalURL}`));
return;
}
const contentType = headers['content-type'];
const resource = this.resourceService.createResourceFromFile(originalURL, localCopy, contentType, logger);
this.responsesProcessed.set(originalURL, resource);
this.responsesProcessed.set(redirectedURL, resource);
return resource;
}
catch (err) {
logger.debug(`${err}`);
logger.debug(logger_1.addLogDate(`Failed to make a local copy of redirected response: ${originalURL}`));
return;
}
}
/**
* Handle processing and saving a resource coming from Puppeteer. This will
* take the response object from Puppeteer and save the asset locally.
*/
async handlePuppeteerResource(url, response, width, logger) {
logger.debug(logger_1.addLogDate(`Making local copy of response: ${response.url()}`));
const buffer = await response.buffer();
const sha = crypto.createHash('sha256').update(buffer).digest('hex');
const localCopy = path.join(os.tmpdir(), sha);
const didWriteFile = this.maybeWriteFile(localCopy, buffer);
if (!didWriteFile) {
logger.debug(logger_1.addLogDate(`Skipping file copy [already_copied]: ${response.url()}`));
}
const contentType = response.headers()['content-type'];
const { fileIsTooLarge, responseBodySize } = this.checkFileSize(localCopy);
if (fileIsTooLarge) {
logger.debug(logger_1.addLogDate(`Skipping [max_file_size_exceeded_${responseBodySize}] [${width} px]: ${response.url()}`));
return;
}
const resource = this.resourceService.createResourceFromFile(url, localCopy, contentType, logger);
this.responsesProcessed.set(url, resource);
return resource;
}
/**
* Write a local copy of the SHA only if it doesn't exist on the file system
* already.
*/
maybeWriteFile(filePath, buffer) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, buffer);
return true;
}
else {
return false;
}
}
/**
* Ensures the saved file is not larger than what the Percy API accepts. It
* returns if the file is too large, as well as the files size.
*/
checkFileSize(filePath) {
const responseBodySize = fs.statSync(filePath).size;
const fileIsTooLarge = responseBodySize > constants_1.default.MAX_FILE_SIZE_BYTES;
return { fileIsTooLarge, responseBodySize };
}
}
exports.default = ResponseService;