@percy/client 1.11.0 → 1.13.0
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/client.js +63 -77
- package/dist/proxy.js +40 -55
- package/dist/utils.js +39 -34
- package/package.json +4 -4
package/dist/client.js
CHANGED
|
@@ -2,39 +2,38 @@ import fs from 'fs';
|
|
|
2
2
|
import PercyEnv from '@percy/env';
|
|
3
3
|
import { git } from '@percy/env/utils';
|
|
4
4
|
import logger from '@percy/logger';
|
|
5
|
-
import { pool, request, sha256hash, base64encode, getPackageJSON } from './utils.js';
|
|
5
|
+
import { pool, request, sha256hash, base64encode, getPackageJSON } from './utils.js';
|
|
6
6
|
|
|
7
|
+
// Default client API URL can be set with an env var for API development
|
|
7
8
|
const {
|
|
8
9
|
PERCY_CLIENT_API_URL = 'https://percy.io/api/v1'
|
|
9
10
|
} = process.env;
|
|
10
|
-
const pkg = getPackageJSON(import.meta.url);
|
|
11
|
+
const pkg = getPackageJSON(import.meta.url);
|
|
11
12
|
|
|
13
|
+
// Validate ID arguments
|
|
12
14
|
function validateId(type, id) {
|
|
13
15
|
if (!id) throw new Error(`Missing ${type} ID`);
|
|
14
|
-
|
|
15
16
|
if (!(typeof id === 'string' || typeof id === 'number')) {
|
|
16
17
|
throw new Error(`Invalid ${type} ID`);
|
|
17
18
|
}
|
|
18
|
-
}
|
|
19
|
-
|
|
19
|
+
}
|
|
20
20
|
|
|
21
|
+
// Validate project path arguments
|
|
21
22
|
function validateProjectPath(path) {
|
|
22
23
|
if (!path) throw new Error('Missing project path');
|
|
23
|
-
|
|
24
24
|
if (!/^[^/]+?\/.+/.test(path)) {
|
|
25
25
|
throw new Error(`Invalid project path. Expected "org/project" but received "${path}"`);
|
|
26
26
|
}
|
|
27
|
-
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// PercyClient is used to communicate with the Percy API to create and finalize
|
|
28
30
|
// builds and snapshot. Uses @percy/env to collect environment information used
|
|
29
31
|
// during build creation.
|
|
30
|
-
|
|
31
|
-
|
|
32
32
|
export class PercyClient {
|
|
33
33
|
log = logger('client');
|
|
34
34
|
env = new PercyEnv(process.env);
|
|
35
35
|
clientInfo = new Set();
|
|
36
36
|
environmentInfo = new Set();
|
|
37
|
-
|
|
38
37
|
constructor({
|
|
39
38
|
// read or write token, defaults to PERCY_TOKEN environment variable
|
|
40
39
|
token,
|
|
@@ -50,55 +49,55 @@ export class PercyClient {
|
|
|
50
49
|
});
|
|
51
50
|
this.addClientInfo(clientInfo);
|
|
52
51
|
this.addEnvironmentInfo(environmentInfo);
|
|
53
|
-
}
|
|
54
|
-
|
|
52
|
+
}
|
|
55
53
|
|
|
54
|
+
// Adds additional unique client info.
|
|
56
55
|
addClientInfo(info) {
|
|
57
56
|
for (let i of [].concat(info)) {
|
|
58
57
|
if (i) this.clientInfo.add(i);
|
|
59
58
|
}
|
|
60
|
-
}
|
|
61
|
-
|
|
59
|
+
}
|
|
62
60
|
|
|
61
|
+
// Adds additional unique environment info.
|
|
63
62
|
addEnvironmentInfo(info) {
|
|
64
63
|
for (let i of [].concat(info)) {
|
|
65
64
|
if (i) this.environmentInfo.add(i);
|
|
66
65
|
}
|
|
67
|
-
}
|
|
68
|
-
|
|
66
|
+
}
|
|
69
67
|
|
|
68
|
+
// Stringifies client and environment info.
|
|
70
69
|
userAgent() {
|
|
71
70
|
let client = new Set([`Percy/${/\w+$/.exec(this.apiUrl)}`].concat(`${pkg.name}/${pkg.version}`, ...this.clientInfo).filter(Boolean));
|
|
72
71
|
let environment = new Set([...this.environmentInfo].concat(`node/${process.version}`, this.env.info).filter(Boolean));
|
|
73
72
|
return `${[...client].join(' ')} (${[...environment].join('; ')})`;
|
|
74
|
-
}
|
|
75
|
-
|
|
73
|
+
}
|
|
76
74
|
|
|
75
|
+
// Checks for a Percy token and returns it.
|
|
77
76
|
getToken() {
|
|
78
77
|
let token = this.token || this.env.token;
|
|
79
78
|
if (!token) throw new Error('Missing Percy token');
|
|
80
79
|
return token;
|
|
81
|
-
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Returns common headers used for each request with additional
|
|
82
83
|
// headers. Throws an error when the token is missing, which is a required
|
|
83
84
|
// authorization header.
|
|
84
|
-
|
|
85
|
-
|
|
86
85
|
headers(headers) {
|
|
87
86
|
return Object.assign({
|
|
88
87
|
Authorization: `Token token=${this.getToken()}`,
|
|
89
88
|
'User-Agent': this.userAgent()
|
|
90
89
|
}, headers);
|
|
91
|
-
}
|
|
92
|
-
|
|
90
|
+
}
|
|
93
91
|
|
|
92
|
+
// Performs a GET request for an API endpoint with appropriate headers.
|
|
94
93
|
get(path) {
|
|
95
94
|
return request(`${this.apiUrl}/${path}`, {
|
|
96
95
|
headers: this.headers(),
|
|
97
96
|
method: 'GET'
|
|
98
97
|
});
|
|
99
|
-
}
|
|
100
|
-
|
|
98
|
+
}
|
|
101
99
|
|
|
100
|
+
// Performs a POST request to a JSON API endpoint with appropriate headers.
|
|
102
101
|
post(path, body = {}) {
|
|
103
102
|
return request(`${this.apiUrl}/${path}`, {
|
|
104
103
|
headers: this.headers({
|
|
@@ -107,11 +106,11 @@ export class PercyClient {
|
|
|
107
106
|
method: 'POST',
|
|
108
107
|
body
|
|
109
108
|
});
|
|
110
|
-
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Creates a build with optional build resources. Only one build can be
|
|
111
112
|
// created at a time per instance so snapshots and build finalization can be
|
|
112
113
|
// done more seemlessly without manually tracking build ids
|
|
113
|
-
|
|
114
|
-
|
|
115
114
|
async createBuild({
|
|
116
115
|
resources = []
|
|
117
116
|
} = {}) {
|
|
@@ -150,10 +149,10 @@ export class PercyClient {
|
|
|
150
149
|
}
|
|
151
150
|
}
|
|
152
151
|
});
|
|
153
|
-
}
|
|
154
|
-
// added as a query param so the API finalizes all other build shards.
|
|
155
|
-
|
|
152
|
+
}
|
|
156
153
|
|
|
154
|
+
// Finalizes the active build. When `all` is true, `all-shards=true` is
|
|
155
|
+
// added as a query param so the API finalizes all other build shards.
|
|
157
156
|
async finalizeBuild(buildId, {
|
|
158
157
|
all = false
|
|
159
158
|
} = {}) {
|
|
@@ -161,25 +160,25 @@ export class PercyClient {
|
|
|
161
160
|
let qs = all ? 'all-shards=true' : '';
|
|
162
161
|
this.log.debug(`Finalizing build ${buildId}...`);
|
|
163
162
|
return this.post(`builds/${buildId}/finalize?${qs}`);
|
|
164
|
-
}
|
|
165
|
-
|
|
163
|
+
}
|
|
166
164
|
|
|
165
|
+
// Retrieves build data by id. Requires a read access token.
|
|
167
166
|
async getBuild(buildId) {
|
|
168
167
|
validateId('build', buildId);
|
|
169
168
|
this.log.debug(`Get build ${buildId}`);
|
|
170
169
|
return this.get(`builds/${buildId}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
170
|
+
}
|
|
173
171
|
|
|
172
|
+
// Retrieves project builds optionally filtered. Requires a read access token.
|
|
174
173
|
async getBuilds(project, filters = {}) {
|
|
175
174
|
validateProjectPath(project);
|
|
176
175
|
let qs = Object.keys(filters).map(k => Array.isArray(filters[k]) ? filters[k].map(v => `filter[${k}][]=${v}`).join('&') : `filter[${k}]=${filters[k]}`).join('&');
|
|
177
176
|
this.log.debug(`Fetching builds for ${project}`);
|
|
178
177
|
return this.get(`projects/${project}/builds?${qs}`);
|
|
179
|
-
}
|
|
180
|
-
// processing. By default, will time out if no update after 10 minutes.
|
|
181
|
-
|
|
178
|
+
}
|
|
182
179
|
|
|
180
|
+
// Resolves when the build has finished and is no longer pending or
|
|
181
|
+
// processing. By default, will time out if no update after 10 minutes.
|
|
183
182
|
waitForBuild({
|
|
184
183
|
build,
|
|
185
184
|
project,
|
|
@@ -194,42 +193,44 @@ export class PercyClient {
|
|
|
194
193
|
} else if (project) {
|
|
195
194
|
validateProjectPath(project);
|
|
196
195
|
}
|
|
197
|
-
|
|
198
196
|
commit || (commit = this.env.git.sha);
|
|
199
197
|
if (!build && !commit) throw new Error('Missing build commit');
|
|
200
198
|
let sha = commit && (git(`rev-parse ${commit}`) || commit);
|
|
201
|
-
|
|
202
199
|
let fetchData = async () => {
|
|
203
200
|
var _await$this$getBuilds;
|
|
204
|
-
|
|
205
201
|
return build ? (await this.getBuild(build)).data : (_await$this$getBuilds = (await this.getBuilds(project, {
|
|
206
202
|
sha
|
|
207
203
|
})).data) === null || _await$this$getBuilds === void 0 ? void 0 : _await$this$getBuilds[0];
|
|
208
204
|
};
|
|
205
|
+
this.log.debug(`Waiting for build ${build || `${project} (${commit})`}...`);
|
|
209
206
|
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
// recursively poll every second until the build finishes
|
|
212
208
|
return new Promise((resolve, reject) => async function poll(last, t) {
|
|
213
209
|
try {
|
|
214
210
|
let data = await fetchData();
|
|
215
211
|
let state = data === null || data === void 0 ? void 0 : data.attributes.state;
|
|
216
212
|
let pending = !state || state === 'pending' || state === 'processing';
|
|
217
|
-
let updated = JSON.stringify(data) !== JSON.stringify(last);
|
|
213
|
+
let updated = JSON.stringify(data) !== JSON.stringify(last);
|
|
218
214
|
|
|
215
|
+
// new data received
|
|
219
216
|
if (updated) {
|
|
220
|
-
t = Date.now();
|
|
217
|
+
t = Date.now();
|
|
218
|
+
|
|
219
|
+
// no new data within the timeout
|
|
221
220
|
} else if (Date.now() - t >= timeout) {
|
|
222
221
|
throw new Error(state == null ? 'Build not found' : 'Timeout exceeded with no updates');
|
|
223
|
-
}
|
|
224
|
-
|
|
222
|
+
}
|
|
225
223
|
|
|
224
|
+
// call progress every update after the first update
|
|
226
225
|
if ((last || pending) && updated) {
|
|
227
226
|
onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
|
|
228
|
-
}
|
|
229
|
-
|
|
227
|
+
}
|
|
230
228
|
|
|
229
|
+
// not finished, poll again
|
|
231
230
|
if (pending) {
|
|
232
|
-
return setTimeout(poll, interval, data, t);
|
|
231
|
+
return setTimeout(poll, interval, data, t);
|
|
232
|
+
|
|
233
|
+
// build finished
|
|
233
234
|
} else {
|
|
234
235
|
// ensure progress is called at least once
|
|
235
236
|
if (!last) onProgress === null || onProgress === void 0 ? void 0 : onProgress(data);
|
|
@@ -241,11 +242,11 @@ export class PercyClient {
|
|
|
241
242
|
reject(err);
|
|
242
243
|
}
|
|
243
244
|
}(null, Date.now()));
|
|
244
|
-
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Uploads a single resource to the active build. If `filepath` is provided,
|
|
245
248
|
// `content` is read from the filesystem. The sha is optional and will be
|
|
246
249
|
// created from `content` if one is not provided.
|
|
247
|
-
|
|
248
|
-
|
|
249
250
|
async uploadResource(buildId, {
|
|
250
251
|
url,
|
|
251
252
|
sha,
|
|
@@ -264,9 +265,9 @@ export class PercyClient {
|
|
|
264
265
|
}
|
|
265
266
|
}
|
|
266
267
|
});
|
|
267
|
-
}
|
|
268
|
-
|
|
268
|
+
}
|
|
269
269
|
|
|
270
|
+
// Uploads resources to the active build concurrently, two at a time.
|
|
270
271
|
async uploadResources(buildId, resources) {
|
|
271
272
|
validateId('build', buildId);
|
|
272
273
|
this.log.debug(`Uploading resources for ${buildId}...`);
|
|
@@ -275,9 +276,9 @@ export class PercyClient {
|
|
|
275
276
|
yield this.uploadResource(buildId, resource);
|
|
276
277
|
}
|
|
277
278
|
}, this, 2);
|
|
278
|
-
}
|
|
279
|
-
|
|
279
|
+
}
|
|
280
280
|
|
|
281
|
+
// Creates a snapshot for the active build using the provided attributes.
|
|
281
282
|
async createSnapshot(buildId, {
|
|
282
283
|
name,
|
|
283
284
|
widths,
|
|
@@ -291,18 +292,14 @@ export class PercyClient {
|
|
|
291
292
|
validateId('build', buildId);
|
|
292
293
|
this.addClientInfo(clientInfo);
|
|
293
294
|
this.addEnvironmentInfo(environmentInfo);
|
|
294
|
-
|
|
295
295
|
if (!this.clientInfo.size || !this.environmentInfo.size) {
|
|
296
296
|
this.log.warn('Warning: Missing `clientInfo` and/or `environmentInfo` properties');
|
|
297
297
|
}
|
|
298
|
-
|
|
299
298
|
this.log.debug(`Creating snapshot: ${name}...`);
|
|
300
|
-
|
|
301
299
|
for (let resource of resources) {
|
|
302
300
|
if (resource.sha || resource.content || !resource.filepath) continue;
|
|
303
301
|
resource.content = await fs.promises.readFile(resource.filepath);
|
|
304
302
|
}
|
|
305
|
-
|
|
306
303
|
return this.post(`builds/${buildId}/snapshots`, {
|
|
307
304
|
data: {
|
|
308
305
|
type: 'snapshots',
|
|
@@ -329,23 +326,21 @@ export class PercyClient {
|
|
|
329
326
|
}
|
|
330
327
|
}
|
|
331
328
|
});
|
|
332
|
-
}
|
|
333
|
-
|
|
329
|
+
}
|
|
334
330
|
|
|
331
|
+
// Finalizes a snapshot.
|
|
335
332
|
async finalizeSnapshot(snapshotId) {
|
|
336
333
|
validateId('snapshot', snapshotId);
|
|
337
334
|
this.log.debug(`Finalizing snapshot ${snapshotId}...`);
|
|
338
335
|
return this.post(`snapshots/${snapshotId}/finalize`);
|
|
339
|
-
}
|
|
340
|
-
// missing resources for the snapshot, and finalizing the snapshot.
|
|
341
|
-
|
|
336
|
+
}
|
|
342
337
|
|
|
338
|
+
// Convenience method for creating a snapshot for the active build, uploading
|
|
339
|
+
// missing resources for the snapshot, and finalizing the snapshot.
|
|
343
340
|
async sendSnapshot(buildId, options) {
|
|
344
341
|
var _snapshot$data$relati, _snapshot$data$relati2;
|
|
345
|
-
|
|
346
342
|
let snapshot = await this.createSnapshot(buildId, options);
|
|
347
343
|
let missing = (_snapshot$data$relati = snapshot.data.relationships) === null || _snapshot$data$relati === void 0 ? void 0 : (_snapshot$data$relati2 = _snapshot$data$relati['missing-resources']) === null || _snapshot$data$relati2 === void 0 ? void 0 : _snapshot$data$relati2.data;
|
|
348
|
-
|
|
349
344
|
if (missing !== null && missing !== void 0 && missing.length) {
|
|
350
345
|
let resources = options.resources.reduce((acc, r) => Object.assign(acc, {
|
|
351
346
|
[r.sha]: r
|
|
@@ -354,11 +349,9 @@ export class PercyClient {
|
|
|
354
349
|
id
|
|
355
350
|
}) => resources[id]));
|
|
356
351
|
}
|
|
357
|
-
|
|
358
352
|
await this.finalizeSnapshot(snapshot.data.id);
|
|
359
353
|
return snapshot;
|
|
360
354
|
}
|
|
361
|
-
|
|
362
355
|
async createComparison(snapshotId, {
|
|
363
356
|
tag,
|
|
364
357
|
tiles = [],
|
|
@@ -366,12 +359,10 @@ export class PercyClient {
|
|
|
366
359
|
} = {}) {
|
|
367
360
|
validateId('snapshot', snapshotId);
|
|
368
361
|
this.log.debug(`Creating comparision: ${tag.name}...`);
|
|
369
|
-
|
|
370
362
|
for (let tile of tiles) {
|
|
371
363
|
if (tile.sha || tile.content || !tile.filepath) continue;
|
|
372
364
|
tile.content = await fs.promises.readFile(tile.filepath);
|
|
373
365
|
}
|
|
374
|
-
|
|
375
366
|
return this.post(`snapshots/${snapshotId}/comparisons`, {
|
|
376
367
|
data: {
|
|
377
368
|
type: 'comparisons',
|
|
@@ -409,7 +400,6 @@ export class PercyClient {
|
|
|
409
400
|
}
|
|
410
401
|
});
|
|
411
402
|
}
|
|
412
|
-
|
|
413
403
|
async uploadComparisonTile(comparisonId, {
|
|
414
404
|
index = 0,
|
|
415
405
|
total = 1,
|
|
@@ -429,7 +419,6 @@ export class PercyClient {
|
|
|
429
419
|
}
|
|
430
420
|
});
|
|
431
421
|
}
|
|
432
|
-
|
|
433
422
|
async uploadComparisonTiles(comparisonId, tiles) {
|
|
434
423
|
validateId('comparison', comparisonId);
|
|
435
424
|
this.log.debug(`Uploading comparison tiles for ${comparisonId}...`);
|
|
@@ -443,13 +432,11 @@ export class PercyClient {
|
|
|
443
432
|
}
|
|
444
433
|
}, this, 2);
|
|
445
434
|
}
|
|
446
|
-
|
|
447
435
|
async finalizeComparison(comparisonId) {
|
|
448
436
|
validateId('comparison', comparisonId);
|
|
449
437
|
this.log.debug(`Finalizing comparison ${comparisonId}...`);
|
|
450
438
|
return this.post(`comparisons/${comparisonId}/finalize`);
|
|
451
439
|
}
|
|
452
|
-
|
|
453
440
|
async sendComparison(buildId, options) {
|
|
454
441
|
let snapshot = await this.createSnapshot(buildId, options);
|
|
455
442
|
let comparison = await this.createComparison(snapshot.data.id, options);
|
|
@@ -457,6 +444,5 @@ export class PercyClient {
|
|
|
457
444
|
await this.finalizeComparison(comparison.data.id);
|
|
458
445
|
return comparison;
|
|
459
446
|
}
|
|
460
|
-
|
|
461
447
|
}
|
|
462
448
|
export default PercyClient;
|
package/dist/proxy.js
CHANGED
|
@@ -4,44 +4,48 @@ import http from 'http';
|
|
|
4
4
|
import https from 'https';
|
|
5
5
|
import logger from '@percy/logger';
|
|
6
6
|
const CRLF = '\r\n';
|
|
7
|
-
const STATUS_REG = /^HTTP\/1.[01] (\d*)/;
|
|
7
|
+
const STATUS_REG = /^HTTP\/1.[01] (\d*)/;
|
|
8
8
|
|
|
9
|
+
// Returns true if the URL hostname matches any patterns
|
|
9
10
|
export function hostnameMatches(patterns, url) {
|
|
10
11
|
let subject = new URL(url);
|
|
12
|
+
|
|
11
13
|
/* istanbul ignore next: only strings are provided internally by the client proxy; core (which
|
|
12
14
|
* borrows this util) sometimes provides an array of patterns or undefined */
|
|
13
|
-
|
|
14
15
|
patterns = typeof patterns === 'string' ? patterns.split(/[\s,]+/) : [].concat(patterns);
|
|
15
|
-
|
|
16
16
|
for (let pattern of patterns) {
|
|
17
17
|
if (pattern === '*') return true;
|
|
18
|
-
if (!pattern) continue;
|
|
18
|
+
if (!pattern) continue;
|
|
19
19
|
|
|
20
|
+
// parse pattern
|
|
20
21
|
let {
|
|
21
22
|
groups: rule
|
|
22
|
-
} = pattern.match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/);
|
|
23
|
+
} = pattern.match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/);
|
|
23
24
|
|
|
25
|
+
// missing a hostname or ports do not match
|
|
24
26
|
if (!rule.hostname || rule.port && rule.port !== subject.port) {
|
|
25
27
|
continue;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
+
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
// wildcards are treated the same as leading dots
|
|
31
|
+
rule.hostname = rule.hostname.replace(/^\*/, '');
|
|
30
32
|
|
|
33
|
+
// hostnames are equal or end with a wildcard rule
|
|
31
34
|
if (rule.hostname === subject.hostname || rule.hostname.startsWith('.') && subject.hostname.endsWith(rule.hostname)) {
|
|
32
35
|
return true;
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
|
-
|
|
36
38
|
return false;
|
|
37
|
-
}
|
|
38
|
-
// protocols or port 80 otherwise.
|
|
39
|
+
}
|
|
39
40
|
|
|
41
|
+
// Returns the port number of a URL object. Defaults to port 443 for https
|
|
42
|
+
// protocols or port 80 otherwise.
|
|
40
43
|
export function port(options) {
|
|
41
44
|
if (options.port) return options.port;
|
|
42
45
|
return options.protocol === 'https:' ? 443 : 80;
|
|
43
|
-
}
|
|
46
|
+
}
|
|
44
47
|
|
|
48
|
+
// Returns a string representation of a URL-like object
|
|
45
49
|
export function href(options) {
|
|
46
50
|
let {
|
|
47
51
|
protocol,
|
|
@@ -53,72 +57,64 @@ export function href(options) {
|
|
|
53
57
|
} = options;
|
|
54
58
|
return `${protocol}//${hostname}:${port(options)}` + (path || `${pathname || ''}${search || ''}${hash || ''}`);
|
|
55
59
|
}
|
|
56
|
-
;
|
|
60
|
+
;
|
|
57
61
|
|
|
62
|
+
// Returns the proxy URL for a set of request options
|
|
58
63
|
export function getProxy(options) {
|
|
59
64
|
let proxyUrl = options.protocol === 'https:' && (process.env.https_proxy || process.env.HTTPS_PROXY) || process.env.http_proxy || process.env.HTTP_PROXY;
|
|
60
65
|
let shouldProxy = !!proxyUrl && !hostnameMatches(process.env.no_proxy || process.env.NO_PROXY, href(options));
|
|
61
|
-
|
|
62
66
|
if (shouldProxy) {
|
|
63
67
|
proxyUrl = new URL(proxyUrl);
|
|
64
68
|
let isHttps = proxyUrl.protocol === 'https:';
|
|
65
|
-
|
|
66
69
|
if (!isHttps && proxyUrl.protocol !== 'http:') {
|
|
67
70
|
throw new Error(`Unsupported proxy protocol: ${proxyUrl.protocol}`);
|
|
68
71
|
}
|
|
69
|
-
|
|
70
72
|
let proxy = {
|
|
71
73
|
isHttps
|
|
72
74
|
};
|
|
73
75
|
proxy.auth = !!proxyUrl.username && 'Basic ' + (proxyUrl.password ? Buffer.from(`${proxyUrl.username}:${proxyUrl.password}`) : Buffer.from(proxyUrl.username)).toString('base64');
|
|
74
76
|
proxy.host = proxyUrl.hostname;
|
|
75
77
|
proxy.port = port(proxyUrl);
|
|
76
|
-
|
|
77
78
|
proxy.connect = () => (isHttps ? tls : net).connect({
|
|
78
79
|
rejectUnauthorized: options.rejectUnauthorized,
|
|
79
80
|
host: proxy.host,
|
|
80
81
|
port: proxy.port
|
|
81
82
|
});
|
|
82
|
-
|
|
83
83
|
return proxy;
|
|
84
84
|
}
|
|
85
|
-
}
|
|
85
|
+
}
|
|
86
86
|
|
|
87
|
+
// Proxified http agent
|
|
87
88
|
export class ProxyHttpAgent extends http.Agent {
|
|
88
89
|
// needed for https proxies
|
|
89
90
|
httpsAgent = new https.Agent({
|
|
90
91
|
keepAlive: true
|
|
91
92
|
});
|
|
92
|
-
|
|
93
93
|
addRequest(request, options) {
|
|
94
94
|
var _request$outputData;
|
|
95
|
-
|
|
96
95
|
let proxy = getProxy(options);
|
|
97
96
|
if (!proxy) return super.addRequest(request, options);
|
|
98
|
-
logger('client:proxy').debug(`Proxying request: ${options.href}`);
|
|
97
|
+
logger('client:proxy').debug(`Proxying request: ${options.href}`);
|
|
99
98
|
|
|
99
|
+
// modify the request for proxying
|
|
100
100
|
request.path = href(options);
|
|
101
|
-
|
|
102
101
|
if (proxy.auth) {
|
|
103
102
|
request.setHeader('Proxy-Authorization', proxy.auth);
|
|
104
|
-
}
|
|
105
|
-
|
|
103
|
+
}
|
|
106
104
|
|
|
105
|
+
// regenerate headers since we just changed things
|
|
107
106
|
delete request._header;
|
|
108
|
-
|
|
109
107
|
request._implicitHeader();
|
|
110
|
-
|
|
111
108
|
if (((_request$outputData = request.outputData) === null || _request$outputData === void 0 ? void 0 : _request$outputData.length) > 0) {
|
|
112
109
|
let first = request.outputData[0].data;
|
|
113
110
|
let endOfHeaders = first.indexOf(CRLF.repeat(2)) + 4;
|
|
114
111
|
request.outputData[0].data = request._header + first.substring(endOfHeaders);
|
|
115
|
-
}
|
|
116
|
-
|
|
112
|
+
}
|
|
117
113
|
|
|
114
|
+
// coerce the connection to the proxy
|
|
118
115
|
options.port = proxy.port;
|
|
119
116
|
options.host = proxy.host;
|
|
120
117
|
delete options.path;
|
|
121
|
-
|
|
122
118
|
if (proxy.isHttps) {
|
|
123
119
|
// use the underlying https agent to complete the connection
|
|
124
120
|
request.agent = this.httpsAgent;
|
|
@@ -127,9 +123,9 @@ export class ProxyHttpAgent extends http.Agent {
|
|
|
127
123
|
return super.addRequest(request, options);
|
|
128
124
|
}
|
|
129
125
|
}
|
|
126
|
+
}
|
|
130
127
|
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
// Proxified https agent
|
|
133
129
|
export class ProxyHttpsAgent extends https.Agent {
|
|
134
130
|
constructor(options) {
|
|
135
131
|
// default keep-alive
|
|
@@ -138,56 +134,47 @@ export class ProxyHttpsAgent extends https.Agent {
|
|
|
138
134
|
...options
|
|
139
135
|
});
|
|
140
136
|
}
|
|
141
|
-
|
|
142
137
|
createConnection(options, callback) {
|
|
143
138
|
let proxy = getProxy(options);
|
|
144
139
|
if (!proxy) return super.createConnection(options, callback);
|
|
145
|
-
logger('client:proxy').debug(`Proxying request: ${href(options)}`);
|
|
140
|
+
logger('client:proxy').debug(`Proxying request: ${href(options)}`);
|
|
146
141
|
|
|
142
|
+
// generate proxy connect message
|
|
147
143
|
let host = `${options.hostname}:${port(options)}`;
|
|
148
144
|
let connectMessage = [`CONNECT ${host} HTTP/1.1`, `Host: ${host}`];
|
|
149
|
-
|
|
150
145
|
if (proxy.auth) {
|
|
151
146
|
connectMessage.push(`Proxy-Authorization: ${proxy.auth}`);
|
|
152
147
|
}
|
|
153
|
-
|
|
154
148
|
connectMessage = connectMessage.join(CRLF);
|
|
155
|
-
connectMessage += CRLF.repeat(2);
|
|
149
|
+
connectMessage += CRLF.repeat(2);
|
|
156
150
|
|
|
151
|
+
// start the proxy connection and setup listeners
|
|
157
152
|
let socket = proxy.connect();
|
|
158
|
-
|
|
159
153
|
let handleError = err => {
|
|
160
154
|
socket.destroy(err);
|
|
161
155
|
callback(err);
|
|
162
156
|
};
|
|
163
|
-
|
|
164
157
|
let handleClose = () => handleError(new Error('Connection closed while sending request to upstream proxy'));
|
|
165
|
-
|
|
166
158
|
let buffer = '';
|
|
167
|
-
|
|
168
159
|
let handleData = data => {
|
|
169
160
|
var _buffer$match;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
161
|
+
buffer += data.toString();
|
|
162
|
+
// haven't received end of headers yet, keep buffering
|
|
163
|
+
if (!buffer.includes(CRLF.repeat(2))) return;
|
|
164
|
+
// stop listening after end of headers
|
|
175
165
|
socket.off('data', handleData);
|
|
176
|
-
|
|
177
166
|
if (((_buffer$match = buffer.match(STATUS_REG)) === null || _buffer$match === void 0 ? void 0 : _buffer$match[1]) !== '200') {
|
|
178
167
|
return handleError(new Error('Error establishing proxy connection. ' + `Response from server was: ${buffer}`));
|
|
179
168
|
}
|
|
180
|
-
|
|
181
169
|
options.socket = socket;
|
|
182
|
-
options.servername = options.hostname;
|
|
183
|
-
|
|
170
|
+
options.servername = options.hostname;
|
|
171
|
+
// callback not passed in so not to be added as a listener
|
|
184
172
|
callback(null, super.createConnection(options));
|
|
185
|
-
};
|
|
186
|
-
|
|
173
|
+
};
|
|
187
174
|
|
|
175
|
+
// send and handle the connect message
|
|
188
176
|
socket.on('error', handleError).on('close', handleClose).on('data', handleData).write(connectMessage);
|
|
189
177
|
}
|
|
190
|
-
|
|
191
178
|
}
|
|
192
179
|
export function proxyAgentFor(url, options) {
|
|
193
180
|
let cache = proxyAgentFor.cache || (proxyAgentFor.cache = new Map());
|
|
@@ -196,10 +183,8 @@ export function proxyAgentFor(url, options) {
|
|
|
196
183
|
hostname
|
|
197
184
|
} = new URL(url);
|
|
198
185
|
let cachekey = `${protocol}//${hostname}`;
|
|
199
|
-
|
|
200
186
|
if (!cache.has(cachekey)) {
|
|
201
187
|
cache.set(cachekey, protocol === 'https:' ? new ProxyHttpsAgent(options) : new ProxyHttpAgent(options));
|
|
202
188
|
}
|
|
203
|
-
|
|
204
189
|
return cache.get(cachekey);
|
|
205
190
|
}
|
package/dist/utils.js
CHANGED
|
@@ -2,16 +2,19 @@ import os from 'os';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import url from 'url';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import crypto from 'crypto';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
6
|
|
|
7
|
+
// Returns a sha256 hash of a string.
|
|
7
8
|
export function sha256hash(content) {
|
|
8
9
|
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
9
|
-
}
|
|
10
|
+
}
|
|
10
11
|
|
|
12
|
+
// Returns a base64 encoding of a string or buffer.
|
|
11
13
|
export function base64encode(content) {
|
|
12
14
|
return Buffer.from(content).toString('base64');
|
|
13
|
-
}
|
|
15
|
+
}
|
|
14
16
|
|
|
17
|
+
// Returns the package.json content at the package path.
|
|
15
18
|
export function getPackageJSON(rel) {
|
|
16
19
|
/* istanbul ignore else: sanity check */
|
|
17
20
|
if (rel.startsWith('file:')) rel = url.fileURLToPath(rel);
|
|
@@ -19,32 +22,31 @@ export function getPackageJSON(rel) {
|
|
|
19
22
|
if (fs.existsSync(pkg)) return JSON.parse(fs.readFileSync(pkg));
|
|
20
23
|
let dir = path.dirname(rel);
|
|
21
24
|
/* istanbul ignore else: sanity check */
|
|
22
|
-
|
|
23
25
|
if (dir !== rel && dir !== os.homedir()) return getPackageJSON(dir);
|
|
24
|
-
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Creates a concurrent pool of promises created by the given generator.
|
|
25
29
|
// Resolves when the generator's final promise resolves and rejects when any
|
|
26
30
|
// generated promise rejects.
|
|
27
|
-
|
|
28
31
|
export function pool(generator, context, concurrency) {
|
|
29
32
|
return new Promise((resolve, reject) => {
|
|
30
33
|
let iterator = generator.call(context);
|
|
31
34
|
let queue = 0;
|
|
32
35
|
let ret = [];
|
|
33
|
-
let err;
|
|
36
|
+
let err;
|
|
34
37
|
|
|
38
|
+
// generates concurrent promises
|
|
35
39
|
let proceed = () => {
|
|
36
40
|
while (queue < concurrency) {
|
|
37
41
|
let {
|
|
38
42
|
done,
|
|
39
43
|
value: promise
|
|
40
44
|
} = iterator.next();
|
|
41
|
-
|
|
42
45
|
if (done || err) {
|
|
43
46
|
if (!queue && err) reject(err);
|
|
44
47
|
if (!queue) resolve(ret);
|
|
45
48
|
return;
|
|
46
49
|
}
|
|
47
|
-
|
|
48
50
|
queue++;
|
|
49
51
|
promise.then(value => {
|
|
50
52
|
queue--;
|
|
@@ -56,49 +58,53 @@ export function pool(generator, context, concurrency) {
|
|
|
56
58
|
proceed();
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
+
};
|
|
61
62
|
|
|
63
|
+
// start generating promises
|
|
62
64
|
proceed();
|
|
63
65
|
});
|
|
64
|
-
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Returns a promise that resolves or rejects when the provided function calls
|
|
65
69
|
// `resolve` or `reject` respectively. The third function argument, `retry`,
|
|
66
70
|
// will recursively call the function at the specified interval until retries
|
|
67
71
|
// are exhausted, at which point the promise will reject with the last error
|
|
68
72
|
// passed to `retry`.
|
|
69
|
-
|
|
70
73
|
export function retry(fn, {
|
|
71
74
|
retries = 5,
|
|
72
75
|
interval = 50
|
|
73
76
|
}) {
|
|
74
77
|
return new Promise((resolve, reject) => {
|
|
75
|
-
let run = () => fn(resolve, reject, retry);
|
|
76
|
-
|
|
78
|
+
let run = () => fn(resolve, reject, retry);
|
|
77
79
|
|
|
80
|
+
// wait an interval to try again or reject with the error
|
|
78
81
|
let retry = err => {
|
|
79
82
|
if (retries-- > 0) {
|
|
80
83
|
setTimeout(run, interval);
|
|
81
84
|
} else {
|
|
82
85
|
reject(err);
|
|
83
86
|
}
|
|
84
|
-
};
|
|
85
|
-
|
|
87
|
+
};
|
|
86
88
|
|
|
89
|
+
// start trying
|
|
87
90
|
run();
|
|
88
91
|
});
|
|
89
|
-
}
|
|
92
|
+
}
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
// Used by the request util when retrying specific errors
|
|
95
|
+
const RETRY_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'EHOSTUNREACH', 'EAI_AGAIN'];
|
|
96
|
+
|
|
97
|
+
// Proxified request function that resolves with the response body when the request is successful
|
|
92
98
|
// and rejects when a non-successful response is received. The rejected error contains response data
|
|
93
99
|
// and any received error details. Server 500 errors are retried up to 5 times at 50ms intervals by
|
|
94
100
|
// default, and 404 errors may also be optionally retried. If a callback is provided, it is called
|
|
95
101
|
// with the parsed response body and response details. If the callback returns a value, that value
|
|
96
102
|
// will be returned in the final resolved promise instead of the response body.
|
|
97
|
-
|
|
98
103
|
export async function request(url, options = {}, callback) {
|
|
99
104
|
// accept `request(url, callback)`
|
|
100
|
-
if (typeof options === 'function') [options, callback] = [{}, options];
|
|
105
|
+
if (typeof options === 'function') [options, callback] = [{}, options];
|
|
101
106
|
|
|
107
|
+
// gather request options
|
|
102
108
|
let {
|
|
103
109
|
body,
|
|
104
110
|
headers,
|
|
@@ -116,24 +122,26 @@ export async function request(url, options = {}, callback) {
|
|
|
116
122
|
pathname,
|
|
117
123
|
search,
|
|
118
124
|
hash
|
|
119
|
-
} = new URL(url);
|
|
125
|
+
} = new URL(url);
|
|
120
126
|
|
|
127
|
+
// reference the default export so tests can mock it
|
|
121
128
|
let {
|
|
122
129
|
default: http
|
|
123
130
|
} = await import(protocol === 'https:' ? 'https' : 'http');
|
|
124
131
|
let {
|
|
125
132
|
proxyAgentFor
|
|
126
|
-
} = await import('./proxy.js');
|
|
133
|
+
} = await import('./proxy.js');
|
|
127
134
|
|
|
135
|
+
// automatically stringify body content
|
|
128
136
|
if (body !== undefined && typeof body !== 'string') {
|
|
129
137
|
headers = {
|
|
130
138
|
'Content-Type': 'application/json',
|
|
131
139
|
...headers
|
|
132
140
|
};
|
|
133
141
|
body = JSON.stringify(body);
|
|
134
|
-
}
|
|
135
|
-
|
|
142
|
+
}
|
|
136
143
|
|
|
144
|
+
// combine request options
|
|
137
145
|
Object.assign(requestOptions, {
|
|
138
146
|
agent: requestOptions.agent || !noProxy && proxyAgentFor(url) || null,
|
|
139
147
|
path: pathname + search + hash,
|
|
@@ -145,33 +153,32 @@ export async function request(url, options = {}, callback) {
|
|
|
145
153
|
return retry((resolve, reject, retry) => {
|
|
146
154
|
let handleError = error => {
|
|
147
155
|
if (handleError.handled) return;
|
|
148
|
-
handleError.handled = true;
|
|
156
|
+
handleError.handled = true;
|
|
149
157
|
|
|
158
|
+
// maybe retry 404s, always retry 500s, or retry specific errors
|
|
150
159
|
let shouldRetry = error.response ? retryNotFound && error.response.statusCode === 404 || error.response.statusCode >= 500 && error.response.statusCode < 600 : !!error.code && RETRY_ERROR_CODES.includes(error.code);
|
|
151
160
|
return shouldRetry ? retry(error) : reject(error);
|
|
152
161
|
};
|
|
153
|
-
|
|
154
162
|
let handleFinished = async (body, res) => {
|
|
155
163
|
let {
|
|
156
164
|
statusCode,
|
|
157
165
|
headers
|
|
158
166
|
} = res;
|
|
159
|
-
let raw = body.toString('utf-8');
|
|
167
|
+
let raw = body.toString('utf-8');
|
|
160
168
|
|
|
161
|
-
|
|
169
|
+
// only return a buffer when requested
|
|
170
|
+
if (buffer !== true) body = raw;
|
|
162
171
|
|
|
172
|
+
// attempt to parse the body as json
|
|
163
173
|
try {
|
|
164
174
|
body = JSON.parse(raw);
|
|
165
175
|
} catch {}
|
|
166
|
-
|
|
167
176
|
try {
|
|
168
177
|
if (statusCode >= 200 && statusCode < 300) {
|
|
169
178
|
var _callback;
|
|
170
|
-
|
|
171
179
|
resolve((await ((_callback = callback) === null || _callback === void 0 ? void 0 : _callback(body, res))) ?? body);
|
|
172
180
|
} else {
|
|
173
181
|
var _body, _body$errors, _body$errors$find;
|
|
174
|
-
|
|
175
182
|
let err = (_body = body) === null || _body === void 0 ? void 0 : (_body$errors = _body.errors) === null || _body$errors === void 0 ? void 0 : (_body$errors$find = _body$errors.find(e => e.detail)) === null || _body$errors$find === void 0 ? void 0 : _body$errors$find.detail;
|
|
176
183
|
throw new Error(err || `${statusCode} ${res.statusMessage || raw}`);
|
|
177
184
|
}
|
|
@@ -186,14 +193,12 @@ export async function request(url, options = {}, callback) {
|
|
|
186
193
|
}));
|
|
187
194
|
}
|
|
188
195
|
};
|
|
189
|
-
|
|
190
196
|
let handleResponse = res => {
|
|
191
197
|
let chunks = [];
|
|
192
198
|
res.on('data', chunk => chunks.push(chunk));
|
|
193
199
|
res.on('end', () => handleFinished(Buffer.concat(chunks), res));
|
|
194
200
|
res.on('error', handleError);
|
|
195
201
|
};
|
|
196
|
-
|
|
197
202
|
let req = http.request(requestOptions);
|
|
198
203
|
req.on('response', handleResponse);
|
|
199
204
|
req.on('error', handleError);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"test:coverage": "yarn test --coverage"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@percy/env": "1.
|
|
35
|
-
"@percy/logger": "1.
|
|
34
|
+
"@percy/env": "1.13.0",
|
|
35
|
+
"@percy/logger": "1.13.0"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "d2e812d14aa446fa580ffa75144a6280627b5a27"
|
|
38
38
|
}
|