UNPKG

@percy/core

Version:

The core component of Percy's CLI and SDKs that handles creating builds, discovering snapshot assets, uploading snapshots, and finalizing builds. Uses `@percy/client` for API communication, a Chromium browser for asset discovery, and starts a local API se

356 lines (332 loc) 14.1 kB
function _classPrivateMethodInitSpec(e, a) { _checkPrivateRedeclaration(e, a), a.add(e); } function _classPrivateFieldInitSpec(e, t, a) { _checkPrivateRedeclaration(e, t), t.set(e, a); } function _checkPrivateRedeclaration(e, t) { if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object"); } function _classPrivateFieldGet(s, a) { return s.get(_assertClassBrand(s, a)); } function _classPrivateFieldSet(s, a, r) { return s.set(_assertClassBrand(s, a), r), r; } function _assertClassBrand(e, t, n) { if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n; throw new TypeError("Private element is not present on this object"); } import fs from 'fs'; import path from 'path'; import http from 'http'; import mime from 'mime-types'; import disposition from 'content-disposition'; import { pathToRegexp, match as pathToMatch, compile as makeToPath } from 'path-to-regexp'; // custom incoming message adds a `url` and `body` properties containing the parsed URL and message // buffer respectively; both available after the 'end' event is emitted export class IncomingMessage extends http.IncomingMessage { constructor(socket) { let buffer = []; super(socket).on('data', d => buffer.push(d)).on('end', () => { var _this$headers$content; this.url = new URL(this.url, `http://${this.headers.host}`); if (buffer.length) this.body = Buffer.concat(buffer); if (this.body && (_this$headers$content = this.headers['content-type']) !== null && _this$headers$content !== void 0 && _this$headers$content.includes('json')) { try { this.body = JSON.parse(this.body); } catch {} } }); } } // custom server response adds additional convenience methods export class ServerResponse extends http.ServerResponse { // responds with a status, headers, and body; the second argument can be an content-type string, // or a headers object, with content-length being automatically set when a `body` is provided send(status, headers, body) { if (typeof headers === 'string') { this.setHeader('Content-Type', headers); headers = null; } if (body != null && !this.hasHeader('Content-Length')) { this.setHeader('Content-Length', Buffer.byteLength(body)); } return this.writeHead(status, headers).end(body); } // responds with a status and content with a plain/text content-type text(status, content) { if (arguments.length < 2) [status, content] = [200, status]; return this.send(status, 'text/plain', content.toString()); } // responds with a status and stringified `data` with a json content-type json(status, data) { if (arguments.length < 2) [status, data] = [200, status]; return this.send(status, 'application/json', JSON.stringify(data)); } // responds with a status and streams a file with appropriate headers file(status, filepath) { if (arguments.length < 2) [status, filepath] = [200, status]; filepath = path.resolve(filepath); let { size } = fs.lstatSync(filepath); let range = parseByteRange(this.req.headers.range, size); // support simple range requests if (this.req.headers.range) { let byteRange = range ? `${range.start}-${range.end}` : '*'; this.setHeader('Content-Range', `bytes ${byteRange}/${size}`); if (!range) return this.send(416); } this.writeHead(range ? 206 : status, { 'Accept-Ranges': 'bytes', 'Content-Type': mime.contentType(path.extname(filepath)), 'Content-Length': range ? range.end - range.start + 1 : size, 'Content-Disposition': disposition(filepath, { type: 'inline' }) }); fs.createReadStream(filepath, range).pipe(this); return this; } } // custom server error with a status and default reason export class ServerError extends Error { static throw(status, reason) { throw new this(status, reason); } constructor(status = 500, reason) { super(reason || http.STATUS_CODES[status]); this.status = status; } } // custom server class handles routing requests and provides alternate methods and properties var _sockets = /*#__PURE__*/new WeakMap(); var _defaultPort = /*#__PURE__*/new WeakMap(); var _routes = /*#__PURE__*/new WeakMap(); var _Server_brand = /*#__PURE__*/new WeakSet(); export class Server extends http.Server { constructor({ port } = {}) { super({ IncomingMessage, ServerResponse }); // adds a route in the correct priority order _classPrivateMethodInitSpec(this, _Server_brand); _classPrivateFieldInitSpec(this, _sockets, new Set()); _classPrivateFieldInitSpec(this, _defaultPort, void 0); // initial routes include cors and 404 handling _classPrivateFieldInitSpec(this, _routes, [{ priority: -1, handle: (req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); if (req.method === 'OPTIONS') { let allowHeaders = req.headers['access-control-request-headers'] || '*'; let allowMethods = [...new Set(_classPrivateFieldGet(_routes, this).flatMap(route => (!route.match || route.match(req.url.pathname)) && route.methods || []))].join(', '); res.setHeader('Access-Control-Allow-Headers', allowHeaders); res.setHeader('Access-Control-Allow-Methods', allowMethods); res.writeHead(204).end(); } else { res.setHeader('Access-Control-Expose-Headers', '*'); return next(); } } }, { priority: 3, handle: req => ServerError.throw(404) }]); _classPrivateFieldSet(_defaultPort, this, port); // handle requests on end this.on('request', (req, res) => { req.on('end', () => _assertClassBrand(_Server_brand, this, _handleRequest).call(this, req, res)); }); // track open connections to terminate when the server closes this.on('connection', socket => { let handleClose = () => _classPrivateFieldGet(_sockets, this).delete(socket); _classPrivateFieldGet(_sockets, this).add(socket.on('close', handleClose)); }); } // return host bind address - defaults to "::" get host() { return process.env.PERCY_SERVER_HOST || '::'; } // return the listening port or any default port get port() { var _super$address; return ((_super$address = super.address()) === null || _super$address === void 0 ? void 0 : _super$address.port) ?? _classPrivateFieldGet(_defaultPort, this); } // return a string representation of the server address address() { let port = this.port; // we need to specifically map "::" to localhost on windows as even though we // can listen to all interfaces using "::" we cant make a request on "::" as // its an invalid ip address as per spec, but unix systems allow request to it and // falls back to localhost let host = `http://${this.host === '::' ? 'localhost' : this.host}`; return port ? `${host}:${port}` : host; } // return a promise that resolves when the server is listening listen(port = _classPrivateFieldGet(_defaultPort, this)) { return new Promise((resolve, reject) => { let handle = err => off() && err ? reject(err) : resolve(this); let off = () => this.off('error', handle).off('listening', handle); super.listen(port, this.host, handle).once('error', handle); }); } // return a promise that resolves when the server closes close() { return new Promise(resolve => { _classPrivateFieldGet(_sockets, this).forEach(socket => socket.destroy()); super.close(resolve); }); } // set request routing and handling for pathnames and methods route(method, pathname, handle) { if (arguments.length === 1) [handle, method] = [method]; if (arguments.length === 2) [handle, pathname] = [pathname]; if (arguments.length === 2 && !Array.isArray(method) && method[0] === '/') [pathname, method] = [method]; return _assertClassBrand(_Server_brand, this, _route).call(this, { priority: !pathname ? 0 : !method ? 1 : 2, methods: method && [].concat(method).map(m => m.toUpperCase()), match: pathname && pathToMatch(pathname), handle }); } // install a route that serves requested files from the provided directory serve(pathname, directory, options) { var _options; if (typeof directory !== 'string') [options, directory] = [directory]; if (!directory) [pathname, directory] = ['/', pathname]; let root = path.resolve(directory); if (!fs.existsSync(root)) throw new Error(`Not found: ${directory}`); let mountPattern = pathToRegexp(pathname, null, { end: false }); let rewritePath = createRewriter((_options = options) === null || _options === void 0 ? void 0 : _options.rewrites, (pathname, rewrite) => { try { let filepath = decodeURIComponent(pathname.replace(mountPattern, '')); if (!isPathInside(root, filepath)) ServerError.throw(); return rewrite(filepath); } catch { throw new ServerError(400); } }); return _assertClassBrand(_Server_brand, this, _route).call(this, { priority: 2, methods: ['GET'], match: pathname => mountPattern.test(pathname), handle: async (req, res, next) => { try { var _options2; let pathname = rewritePath(req.url.pathname); let file = await getFile(root, pathname, (_options2 = options) === null || _options2 === void 0 ? void 0 : _options2.cleanUrls); if (!(file !== null && file !== void 0 && file.stats.isFile())) return await next(); return res.file(file.path); } catch (err) { let statusPage = path.join(root, `${err.status}.html`); if (!fs.existsSync(statusPage)) throw err; return res.file(err.status, statusPage); } } }); } // route and respond to requests; handling errors if necessary } // create a url rewriter from provided rewrite rules function _route(route) { let i = _classPrivateFieldGet(_routes, this).findIndex(r => r.priority >= route.priority); _classPrivateFieldGet(_routes, this).splice(i, 0, route); return this; } async function _handleRequest(req, res) { // support node < 15.7.0 res.req ?? (res.req = req); try { // invoke routes like middleware await async function cont(routes, i = 0) { let next = () => cont(routes, i + 1); let { methods, match, handle } = routes[i]; let result = !methods || methods.includes(req.method); result && (result = !match || match(req.url.pathname)); if (result) req.params = result.params; return result ? handle(req, res, next) : next(); }(_classPrivateFieldGet(_routes, this)); } catch (error) { var _req$headers$accept, _req$headers$content; let { status = 500, message } = error; // fallback error handling if ((_req$headers$accept = req.headers.accept) !== null && _req$headers$accept !== void 0 && _req$headers$accept.includes('json') || (_req$headers$content = req.headers['content-type']) !== null && _req$headers$content !== void 0 && _req$headers$content.includes('json')) { res.json(status, { error: message }); } else { res.text(status, message); } } } function createRewriter(rewrites = [], cb) { let normalize = p => path.posix.normalize(path.posix.join('/', p)); if (!Array.isArray(rewrites)) rewrites = Object.entries(rewrites); let rewrite = [{ // resolve and normalize the path before rewriting apply: p => path.posix.resolve(normalize(p)) }].concat(rewrites.map(([src, dest]) => { // compile rewrite rules into functions let match = pathToMatch(normalize(src)); let toPath = makeToPath(normalize(dest)); return { match, apply: r => toPath(r.params) }; })).reduceRight((next, rule) => pathname => { var _rule$match; // compose all rewrites into a single function let result = ((_rule$match = rule.match) === null || _rule$match === void 0 ? void 0 : _rule$match.call(rule, pathname)) ?? pathname; if (result) pathname = rule.apply(result); return next(pathname); }, p => p); // allow additional pathname processing around the rewriter return p => cb(p, rewrite); } // returns true if the pathname is inside the root pathname function isPathInside(root, pathname) { let abs = path.resolve(path.join(root, pathname)); return !abs.lastIndexOf(root, 0) && (abs[root.length] === path.sep || !abs[root.length]); } // get the absolute path and stats of a possible file async function getFile(root, pathname, cleanUrls) { for (let filename of [pathname].concat(cleanUrls ? path.join(pathname, 'index.html') : [], cleanUrls && pathname.length > 2 ? pathname.replace(/\/?$/, '.html') : [])) { let filepath = path.resolve(path.join(root, filename)); let stats = await fs.promises.lstat(filepath).catch(() => {}); if (stats !== null && stats !== void 0 && stats.isFile()) return { path: filepath, stats }; } } // returns the start and end of a byte range or undefined if unable to parse const RANGE_REGEXP = /^bytes=(\d*)?-(\d*)?(?:\b|$)/; function parseByteRange(range, size) { let [, start, end = size] = (range === null || range === void 0 ? void 0 : range.match(RANGE_REGEXP)) ?? [0, 0, 0]; start = Math.max(parseInt(start, 10), 0); end = Math.min(parseInt(end, 10), size - 1); if (isNaN(start)) [start, end] = [size - end, size - 1]; if (start >= 0 && start < end) return { start, end }; } // shorthand function for creating a new server with specific options export function createServer(options = {}) { let { serve, port, baseUrl = '/', ...opts } = options; let server = new Server({ port }); return serve ? server.serve(baseUrl, serve, opts) : server; } // include some exports as static properties Server.Error = ServerError; Server.createRewriter = createRewriter; Server.createServer = createServer; export default Server;