@percy/core 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,430 @@
1
+ function _classPrivateMethodInitSpec(obj, privateSet) { _checkPrivateRedeclaration(obj, privateSet); privateSet.add(obj); }
2
+
3
+ function _classPrivateFieldInitSpec(obj, privateMap, value) { _checkPrivateRedeclaration(obj, privateMap); privateMap.set(obj, value); }
4
+
5
+ function _checkPrivateRedeclaration(obj, privateCollection) { if (privateCollection.has(obj)) { throw new TypeError("Cannot initialize the same private elements twice on an object"); } }
6
+
7
+ function _classPrivateFieldGet(receiver, privateMap) { var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "get"); return _classApplyDescriptorGet(receiver, descriptor); }
8
+
9
+ function _classApplyDescriptorGet(receiver, descriptor) { if (descriptor.get) { return descriptor.get.call(receiver); } return descriptor.value; }
10
+
11
+ function _classPrivateMethodGet(receiver, privateSet, fn) { if (!privateSet.has(receiver)) { throw new TypeError("attempted to get private field on non-instance"); } return fn; }
12
+
13
+ function _classPrivateFieldSet(receiver, privateMap, value) { var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "set"); _classApplyDescriptorSet(receiver, descriptor, value); return value; }
14
+
15
+ function _classExtractFieldDescriptor(receiver, privateMap, action) { if (!privateMap.has(receiver)) { throw new TypeError("attempted to " + action + " private field on non-instance"); } return privateMap.get(receiver); }
16
+
17
+ function _classApplyDescriptorSet(receiver, descriptor, value) { if (descriptor.set) { descriptor.set.call(receiver, value); } else { if (!descriptor.writable) { throw new TypeError("attempted to set read only private field"); } descriptor.value = value; } }
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import http from 'http';
22
+ import { WebSocketServer } from 'ws';
23
+ import mime from 'mime-types';
24
+ import disposition from 'content-disposition';
25
+ 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
26
+ // buffer respectively; both available after the 'end' event is emitted
27
+
28
+ export class IncomingMessage extends http.IncomingMessage {
29
+ constructor(socket) {
30
+ let buffer = [];
31
+ super(socket).on('data', d => buffer.push(d)).on('end', () => {
32
+ var _this$headers$content;
33
+
34
+ this.url = new URL(this.url, `http://${this.headers.host}`);
35
+ if (buffer.length) this.body = Buffer.concat(buffer);
36
+
37
+ if (this.body && (_this$headers$content = this.headers['content-type']) !== null && _this$headers$content !== void 0 && _this$headers$content.includes('json')) {
38
+ try {
39
+ this.body = JSON.parse(this.body);
40
+ } catch {}
41
+ }
42
+ });
43
+ }
44
+
45
+ } // custom server response adds additional convenience methods
46
+
47
+ export class ServerResponse extends http.ServerResponse {
48
+ // responds with a status, headers, and body; the second argument can be an content-type string,
49
+ // or a headers object, with content-length being automatically set when a `body` is provided
50
+ send(status, headers, body) {
51
+ if (typeof headers === 'string') {
52
+ this.setHeader('Content-Type', headers);
53
+ headers = null;
54
+ }
55
+
56
+ if (body != null && !this.hasHeader('Content-Length')) {
57
+ this.setHeader('Content-Length', Buffer.byteLength(body));
58
+ }
59
+
60
+ return this.writeHead(status, headers).end(body);
61
+ } // responds with a status and content with a plain/text content-type
62
+
63
+
64
+ text(status, content) {
65
+ if (arguments.length < 2) [status, content] = [200, status];
66
+ return this.send(status, 'text/plain', content.toString());
67
+ } // responds with a status and stringified `data` with a json content-type
68
+
69
+
70
+ json(status, data) {
71
+ if (arguments.length < 2) [status, data] = [200, status];
72
+ return this.send(status, 'application/json', JSON.stringify(data));
73
+ } // responds with a status and streams a file with appropriate headers
74
+
75
+
76
+ file(status, filepath) {
77
+ if (arguments.length < 2) [status, filepath] = [200, status];
78
+ filepath = path.resolve(filepath);
79
+ let {
80
+ size
81
+ } = fs.lstatSync(filepath);
82
+ let range = parseByteRange(this.req.headers.range, size); // support simple range requests
83
+
84
+ if (this.req.headers.range) {
85
+ let byteRange = range ? `${range.start}-${range.end}` : '*';
86
+ this.setHeader('Content-Range', `bytes ${byteRange}/${size}`);
87
+ if (!range) return this.send(416);
88
+ }
89
+
90
+ this.writeHead(range ? 206 : status, {
91
+ 'Accept-Ranges': 'bytes',
92
+ 'Content-Type': mime.contentType(path.extname(filepath)),
93
+ 'Content-Length': range ? range.end - range.start + 1 : size,
94
+ 'Content-Disposition': disposition(filepath, {
95
+ type: 'inline'
96
+ })
97
+ });
98
+ fs.createReadStream(filepath, range).pipe(this);
99
+ return this;
100
+ }
101
+
102
+ } // custom server error with a status and default reason
103
+
104
+ export class ServerError extends Error {
105
+ static throw(status, reason) {
106
+ throw new this(status, reason);
107
+ }
108
+
109
+ constructor(status = 500, reason) {
110
+ super(reason || http.STATUS_CODES[status]);
111
+ this.status = status;
112
+ }
113
+
114
+ } // custom server class handles routing requests and provides alternate methods and properties
115
+
116
+ var _sockets = /*#__PURE__*/new WeakMap();
117
+
118
+ var _defaultPort = /*#__PURE__*/new WeakMap();
119
+
120
+ var _up = /*#__PURE__*/new WeakMap();
121
+
122
+ var _handleUpgrade = /*#__PURE__*/new WeakSet();
123
+
124
+ var _routes = /*#__PURE__*/new WeakMap();
125
+
126
+ var _route = /*#__PURE__*/new WeakSet();
127
+
128
+ var _handleRequest = /*#__PURE__*/new WeakSet();
129
+
130
+ export class Server extends http.Server {
131
+ constructor({
132
+ port
133
+ } = {}) {
134
+ super({
135
+ IncomingMessage,
136
+ ServerResponse
137
+ });
138
+
139
+ _classPrivateMethodInitSpec(this, _handleRequest);
140
+
141
+ _classPrivateMethodInitSpec(this, _route);
142
+
143
+ _classPrivateMethodInitSpec(this, _handleUpgrade);
144
+
145
+ _classPrivateFieldInitSpec(this, _sockets, {
146
+ writable: true,
147
+ value: new Set()
148
+ });
149
+
150
+ _classPrivateFieldInitSpec(this, _defaultPort, {
151
+ writable: true,
152
+ value: void 0
153
+ });
154
+
155
+ _classPrivateFieldInitSpec(this, _up, {
156
+ writable: true,
157
+ value: []
158
+ });
159
+
160
+ _classPrivateFieldInitSpec(this, _routes, {
161
+ writable: true,
162
+ value: [{
163
+ priority: -1,
164
+ handle: (req, res, next) => {
165
+ res.setHeader('Access-Control-Allow-Origin', '*');
166
+
167
+ if (req.method === 'OPTIONS') {
168
+ let allowHeaders = req.headers['access-control-request-headers'] || '*';
169
+ let allowMethods = [...new Set(_classPrivateFieldGet(this, _routes).flatMap(route => (!route.match || route.match(req.url.pathname)) && route.methods || []))].join(', ');
170
+ res.setHeader('Access-Control-Allow-Headers', allowHeaders);
171
+ res.setHeader('Access-Control-Allow-Methods', allowMethods);
172
+ res.writeHead(204).end();
173
+ } else {
174
+ res.setHeader('Access-Control-Expose-Headers', '*');
175
+ return next();
176
+ }
177
+ }
178
+ }, {
179
+ priority: 3,
180
+ handle: req => ServerError.throw(404)
181
+ }]
182
+ });
183
+
184
+ _classPrivateFieldSet(this, _defaultPort, port); // handle requests on end
185
+
186
+
187
+ this.on('request', (req, res) => {
188
+ req.on('end', () => _classPrivateMethodGet(this, _handleRequest, _handleRequest2).call(this, req, res));
189
+ }); // handle websocket upgrades
190
+
191
+ this.on('upgrade', (req, sock, head) => {
192
+ _classPrivateMethodGet(this, _handleUpgrade, _handleUpgrade2).call(this, req, sock, head);
193
+ }); // track open connections to terminate when the server closes
194
+
195
+ this.on('connection', socket => {
196
+ let handleClose = () => _classPrivateFieldGet(this, _sockets).delete(socket);
197
+
198
+ _classPrivateFieldGet(this, _sockets).add(socket.on('close', handleClose));
199
+ });
200
+ } // return the listening port or any default port
201
+
202
+
203
+ get port() {
204
+ var _super$address;
205
+
206
+ return ((_super$address = super.address()) === null || _super$address === void 0 ? void 0 : _super$address.port) ?? _classPrivateFieldGet(this, _defaultPort);
207
+ } // return a string representation of the server address
208
+
209
+
210
+ address() {
211
+ let port = this.port;
212
+ let host = 'http://localhost';
213
+ return port ? `${host}:${port}` : host;
214
+ } // return a promise that resolves when the server is listening
215
+
216
+
217
+ listen(port = _classPrivateFieldGet(this, _defaultPort)) {
218
+ return new Promise((resolve, reject) => {
219
+ let handle = err => off() && err ? reject(err) : resolve(this);
220
+
221
+ let off = () => this.off('error', handle).off('listening', handle);
222
+
223
+ super.listen(port, handle).once('error', handle);
224
+ });
225
+ } // return a promise that resolves when the server closes
226
+
227
+
228
+ close() {
229
+ return new Promise(resolve => {
230
+ _classPrivateFieldGet(this, _sockets).forEach(socket => socket.destroy());
231
+
232
+ super.close(resolve);
233
+ });
234
+ } // handle websocket upgrades
235
+
236
+
237
+ websocket(pathname, handle) {
238
+ if (!handle) [pathname, handle] = [null, pathname];
239
+
240
+ _classPrivateFieldGet(this, _up).push({
241
+ match: pathname && pathToMatch(pathname),
242
+ handle: (req, sock, head) => new Promise(resolve => {
243
+ let wss = new WebSocketServer({
244
+ noServer: true,
245
+ clientTracking: false
246
+ });
247
+ wss.handleUpgrade(req, sock, head, resolve);
248
+ }).then(ws => handle(ws, req))
249
+ });
250
+
251
+ if (pathname) {
252
+ _classPrivateFieldGet(this, _up).sort((a, b) => (a.match ? -1 : 1) - (b.match ? -1 : 1));
253
+ }
254
+
255
+ return this;
256
+ }
257
+
258
+ // set request routing and handling for pathnames and methods
259
+ route(method, pathname, handle) {
260
+ if (arguments.length === 1) [handle, method] = [method];
261
+ if (arguments.length === 2) [handle, pathname] = [pathname];
262
+ if (arguments.length === 2 && !Array.isArray(method) && method[0] === '/') [pathname, method] = [method];
263
+ return _classPrivateMethodGet(this, _route, _route2).call(this, {
264
+ priority: !pathname ? 0 : !method ? 1 : 2,
265
+ methods: method && [].concat(method).map(m => m.toUpperCase()),
266
+ match: pathname && pathToMatch(pathname),
267
+ handle
268
+ });
269
+ } // install a route that serves requested files from the provided directory
270
+
271
+
272
+ serve(pathname, directory, options) {
273
+ var _options;
274
+
275
+ if (typeof directory !== 'string') [options, directory] = [directory];
276
+ if (!directory) [pathname, directory] = ['/', pathname];
277
+ let root = path.resolve(directory);
278
+ if (!fs.existsSync(root)) throw new Error(`Not found: ${directory}`);
279
+ let mountPattern = pathToRegexp(pathname, null, {
280
+ end: false
281
+ });
282
+ let rewritePath = createRewriter((_options = options) === null || _options === void 0 ? void 0 : _options.rewrites, (pathname, rewrite) => {
283
+ try {
284
+ let filepath = decodeURIComponent(pathname.replace(mountPattern, ''));
285
+ if (!isPathInside(root, filepath)) ServerError.throw();
286
+ return rewrite(filepath);
287
+ } catch {
288
+ throw new ServerError(400);
289
+ }
290
+ });
291
+ return _classPrivateMethodGet(this, _route, _route2).call(this, {
292
+ priority: 2,
293
+ methods: ['GET'],
294
+ match: pathname => mountPattern.test(pathname),
295
+ handle: async (req, res, next) => {
296
+ try {
297
+ var _options2;
298
+
299
+ let pathname = rewritePath(req.url.pathname);
300
+ let file = await getFile(root, pathname, (_options2 = options) === null || _options2 === void 0 ? void 0 : _options2.cleanUrls);
301
+ if (!(file !== null && file !== void 0 && file.stats.isFile())) return await next();
302
+ return res.file(file.path);
303
+ } catch (err) {
304
+ let statusPage = path.join(root, `${err.status}.html`);
305
+ if (!fs.existsSync(statusPage)) throw err;
306
+ return res.file(err.status, statusPage);
307
+ }
308
+ }
309
+ });
310
+ } // route and respond to requests; handling errors if necessary
311
+
312
+
313
+ } // create a url rewriter from provided rewrite rules
314
+
315
+ function _handleUpgrade2(req, sock, head) {
316
+ let up = _classPrivateFieldGet(this, _up).find(u => !u.match || u.match(req.url));
317
+
318
+ if (up) return up.handle(req, sock, head);
319
+ sock.write(`HTTP/1.1 400 ${http.STATUS_CODES[400]}\r\n` + 'Connection: close\r\n\r\n');
320
+ sock.destroy();
321
+ }
322
+
323
+ function _route2(route) {
324
+ let i = _classPrivateFieldGet(this, _routes).findIndex(r => r.priority >= route.priority);
325
+
326
+ _classPrivateFieldGet(this, _routes).splice(i, 0, route);
327
+
328
+ return this;
329
+ }
330
+
331
+ async function _handleRequest2(req, res) {
332
+ // support node < 15.7.0
333
+ res.req ?? (res.req = req);
334
+
335
+ try {
336
+ // invoke routes like middleware
337
+ await async function cont(routes, i = 0) {
338
+ let next = () => cont(routes, i + 1);
339
+
340
+ let {
341
+ methods,
342
+ match,
343
+ handle
344
+ } = routes[i];
345
+ let result = !methods || methods.includes(req.method);
346
+ result && (result = !match || match(req.url.pathname));
347
+ if (result) req.params = result.params;
348
+ return result ? handle(req, res, next) : next();
349
+ }(_classPrivateFieldGet(this, _routes));
350
+ } catch (error) {
351
+ var _req$headers$accept, _req$headers$content;
352
+
353
+ let {
354
+ status = 500,
355
+ message
356
+ } = error; // fallback error handling
357
+
358
+ 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')) {
359
+ res.json(status, {
360
+ error: message
361
+ });
362
+ } else {
363
+ res.text(status, message);
364
+ }
365
+ }
366
+ }
367
+
368
+ function createRewriter(rewrites = [], cb) {
369
+ let normalize = p => path.posix.normalize(path.posix.join('/', p));
370
+
371
+ if (!Array.isArray(rewrites)) rewrites = Object.entries(rewrites);
372
+ let rewrite = [{
373
+ // resolve and normalize the path before rewriting
374
+ apply: p => path.posix.resolve(normalize(p))
375
+ }].concat(rewrites.map(([src, dest]) => {
376
+ // compile rewrite rules into functions
377
+ let match = pathToMatch(normalize(src));
378
+ let toPath = makeToPath(normalize(dest));
379
+ return {
380
+ match,
381
+ apply: r => toPath(r.params)
382
+ };
383
+ })).reduceRight((next, rule) => pathname => {
384
+ var _rule$match;
385
+
386
+ // compose all rewrites into a single function
387
+ let result = ((_rule$match = rule.match) === null || _rule$match === void 0 ? void 0 : _rule$match.call(rule, pathname)) ?? pathname;
388
+ if (result) pathname = rule.apply(result);
389
+ return next(pathname);
390
+ }, p => p); // allow additional pathname processing around the rewriter
391
+
392
+ return p => cb(p, rewrite);
393
+ } // returns true if the pathname is inside the root pathname
394
+
395
+
396
+ function isPathInside(root, pathname) {
397
+ let abs = path.resolve(path.join(root, pathname));
398
+ return !abs.lastIndexOf(root, 0) && (abs[root.length] === path.sep || !abs[root.length]);
399
+ } // get the absolute path and stats of a possible file
400
+
401
+
402
+ async function getFile(root, pathname, cleanUrls) {
403
+ for (let filename of [pathname].concat(cleanUrls ? path.join(pathname, 'index.html') : [], cleanUrls && pathname.length > 2 ? pathname.replace(/\/?$/, '.html') : [])) {
404
+ let filepath = path.resolve(path.join(root, filename));
405
+ let stats = await fs.promises.lstat(filepath).catch(() => {});
406
+ if (stats !== null && stats !== void 0 && stats.isFile()) return {
407
+ path: filepath,
408
+ stats
409
+ };
410
+ }
411
+ } // returns the start and end of a byte range or undefined if unable to parse
412
+
413
+
414
+ const RANGE_REGEXP = /^bytes=(\d*)?-(\d*)?(?:\b|$)/;
415
+
416
+ function parseByteRange(range, size) {
417
+ let [, start, end = size] = (range === null || range === void 0 ? void 0 : range.match(RANGE_REGEXP)) ?? [0, 0, 0];
418
+ start = Math.max(parseInt(start, 10), 0);
419
+ end = Math.min(parseInt(end, 10), size - 1);
420
+ if (isNaN(start)) [start, end] = [size - end, size - 1];
421
+ if (start >= 0 && start < end) return {
422
+ start,
423
+ end
424
+ };
425
+ } // include ServerError and createRewriter as static properties
426
+
427
+
428
+ Server.Error = ServerError;
429
+ Server.createRewriter = createRewriter;
430
+ export default Server;
@@ -0,0 +1,103 @@
1
+ import EventEmitter from 'events';
2
+ import logger from '@percy/logger';
3
+ export class Session extends EventEmitter {
4
+ #callbacks = new Map();
5
+ log = logger('core:session');
6
+ children = new Map();
7
+
8
+ constructor(browser, {
9
+ params,
10
+ sessionId: parentId
11
+ }) {
12
+ var _this$parent;
13
+
14
+ super();
15
+ this.browser = browser;
16
+ this.sessionId = params.sessionId;
17
+ this.targetId = params.targetInfo.targetId;
18
+ this.type = params.targetInfo.type;
19
+ this.isDocument = this.type === 'page' || this.type === 'iframe';
20
+ this.parent = browser.sessions.get(parentId);
21
+ (_this$parent = this.parent) === null || _this$parent === void 0 ? void 0 : _this$parent.children.set(this.sessionId, this);
22
+ this.on('Inspector.targetCrashed', this._handleTargetCrashed);
23
+ }
24
+
25
+ async close() {
26
+ if (!this.browser) return;
27
+ await this.browser.send('Target.closeTarget', {
28
+ targetId: this.targetId
29
+ }).catch(this._handleClosedError);
30
+ }
31
+
32
+ async send(method, params) {
33
+ /* istanbul ignore next: race condition paranoia */
34
+ if (this.closedReason) {
35
+ throw new Error(`Protocol error (${method}): ${this.closedReason}`);
36
+ } // send a raw message to the browser so we can provide a sessionId
37
+
38
+
39
+ let id = await this.browser.send({
40
+ sessionId: this.sessionId,
41
+ method,
42
+ params
43
+ }); // will resolve or reject when a matching response is received
44
+
45
+ return new Promise((resolve, reject) => {
46
+ this.#callbacks.set(id, {
47
+ error: new Error(),
48
+ resolve,
49
+ reject,
50
+ method
51
+ });
52
+ });
53
+ }
54
+
55
+ _handleMessage(data) {
56
+ if (data.id && this.#callbacks.has(data.id)) {
57
+ // resolve or reject a pending promise created with #send()
58
+ let callback = this.#callbacks.get(data.id);
59
+ this.#callbacks.delete(data.id);
60
+ /* istanbul ignore next: races with browser._handleMessage() */
61
+
62
+ if (data.error) {
63
+ callback.reject(Object.assign(callback.error, {
64
+ message: `Protocol error (${callback.method}): ${data.error.message}` + ('data' in data.error ? `: ${data.error.data}` : '')
65
+ }));
66
+ } else {
67
+ callback.resolve(data.result);
68
+ }
69
+ } else {
70
+ // emit the message as an event
71
+ this.emit(data.method, data.params);
72
+ }
73
+ }
74
+
75
+ _handleClose() {
76
+ var _this$parent2;
77
+
78
+ this.closedReason || (this.closedReason = 'Session closed.'); // reject any pending callbacks
79
+
80
+ for (let callback of this.#callbacks.values()) {
81
+ callback.reject(Object.assign(callback.error, {
82
+ message: `Protocol error (${callback.method}): ${this.closedReason}`
83
+ }));
84
+ }
85
+
86
+ this.#callbacks.clear();
87
+ (_this$parent2 = this.parent) === null || _this$parent2 === void 0 ? void 0 : _this$parent2.children.delete(this.sessionId);
88
+ this.browser = null;
89
+ }
90
+
91
+ _handleTargetCrashed = () => {
92
+ this.closedReason = 'Session crashed!';
93
+ this.close();
94
+ };
95
+ /* istanbul ignore next: encountered during closing races */
96
+
97
+ _handleClosedError = error => {
98
+ if (!error.message.endsWith(this.closedReason)) {
99
+ this.log.debug(error, this.meta);
100
+ }
101
+ };
102
+ }
103
+ export default Session;