Skip to content

Commit 150057a

Browse files
calebebyemersonthis
andauthored
Make lighthouse reports concurrent (#53)
Co-authored-by: emersonthis <emerson@cloudfour.com>
1 parent 08abdf0 commit 150057a

10 files changed

Lines changed: 322 additions & 40 deletions

File tree

.changeset/polite-foxes-compete.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'lighthouse-parade': minor
3+
---
4+
5+
Run lighthouse instances concurrently, and change CLI output

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
.DS_Store
2-
data
2+
lighthouse-parade-data
33
node_modules
44
/dist

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ Runs a crawler on the provided URL. Discovers all URLs and runs a lighthouse rep
3737
### Options
3838

3939
```
40-
--ignore-robots Crawl pages even if they are listed in the site's robots.txt (default false)
41-
--crawler-user-agent Pass a user agent string to be used by the crawler (not by Lighthouse)
42-
-v, --version Displays current version
43-
-h, --help Displays help text
40+
--ignore-robots Crawl pages even if they are listed in the site's robots.txt (default: false)
41+
--crawler-user-agent Pass a user agent string to be used by the crawler (not by Lighthouse)
42+
--lighthouse-concurrency Control the maximum number of ligthhouse reports to run concurrently (default: number of CPU cores minus one)
43+
-v, --version Displays current version
44+
-h, --help Displays help text
4445
```
4546

4647
## Versioning Notice

cli.ts

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env node
22

33
import * as fs from 'fs';
4+
import * as os from 'os';
5+
import * as kleur from 'kleur/colors';
6+
import logUpdate from 'log-update';
47
import * as path from 'path';
58
import sade from 'sade';
69
import { scan } from './scan-task';
@@ -16,6 +19,11 @@ It may in the future make sense to use a bundler to combine all the dist/ files
1619
// eslint-disable-next-line @cloudfour/typescript-eslint/no-var-requires
1720
const { version } = require('../package.json');
1821

22+
const symbols = {
23+
error: kleur.red('✖'),
24+
success: kleur.green('✔'),
25+
};
26+
1927
sade('lighthouse-parade <url> [dataDirectory]', true)
2028
.version(version)
2129
.describe(
@@ -30,6 +38,11 @@ sade('lighthouse-parade <url> [dataDirectory]', true)
3038
'--crawler-user-agent',
3139
'Pass a user agent string to be used by the crawler (not by Lighthouse)'
3240
)
41+
.option(
42+
'--lighthouse-concurrency',
43+
'Control the maximum number of ligthhouse reports to run concurrently',
44+
os.cpus().length - 1
45+
)
3346
.action(
3447
(
3548
url,
@@ -54,14 +67,96 @@ sade('lighthouse-parade <url> [dataDirectory]', true)
5467
throw new Error('--crawler-user-agent flag must be a string');
5568
}
5669

57-
const scanner = scan(url, { ignoreRobotsTxt, dataDirectory });
70+
const lighthouseConcurrency = opts['lighthouse-concurrency'];
71+
72+
const scanner = scan(url, {
73+
ignoreRobotsTxt,
74+
dataDirectory,
75+
lighthouseConcurrency,
76+
});
77+
78+
const enum State {
79+
Pending,
80+
ReportInProgress,
81+
ReportComplete,
82+
}
83+
const urlStates = new Map<
84+
string,
85+
{ state: State; error?: Error | string }
86+
>();
87+
88+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
89+
let i = 0;
90+
91+
const printLine = (url: string, state: State, error?: Error | string) => {
92+
const frame = kleur.blue(frames[i]);
93+
const statusIcon = error
94+
? symbols.error
95+
: state === State.Pending
96+
? ' '
97+
: state === State.ReportInProgress
98+
? frame
99+
: symbols.success;
100+
let output = `${statusIcon} ${url}`;
101+
if (error) {
102+
output += `\n ${kleur.gray(error.toString())}`;
103+
}
104+
105+
return output;
106+
};
107+
108+
const render = () => {
109+
const pendingUrls: string[] = [];
110+
const currentUrls: string[] = [];
111+
urlStates.forEach(({ state, error }, url) => {
112+
if (state === State.ReportComplete) return;
113+
const line = `${printLine(url, state, error)}\n`;
114+
if (state === State.Pending) pendingUrls.push(line);
115+
else currentUrls.push(line);
116+
});
117+
const numPendingToDisplay = Math.min(
118+
Math.max(process.stdout.rows - currentUrls.length - 3, 1),
119+
pendingUrls.length
120+
);
121+
const numHiddenUrls =
122+
numPendingToDisplay === pendingUrls.length
123+
? ''
124+
: kleur.dim(
125+
`\n...And ${
126+
pendingUrls.length - numPendingToDisplay
127+
} more pending`
128+
);
129+
logUpdate(
130+
currentUrls.join('') +
131+
pendingUrls.slice(0, numPendingToDisplay).join('') +
132+
numHiddenUrls
133+
);
134+
};
135+
136+
const intervalId = setInterval(() => {
137+
i = (i + 1) % frames.length;
138+
render();
139+
}, 80);
140+
141+
/**
142+
* Allows you to run a console.log that will output _above_ the persistent logUpdate log
143+
* Pass a callback where you run your console.log or console.error
144+
*/
145+
const printAboveLogUpdate = (cb: () => void) => {
146+
logUpdate.clear();
147+
cb();
148+
render();
149+
};
150+
151+
const log = (...messages: string[]) =>
152+
printAboveLogUpdate(() => console.log(...messages));
58153

59154
const urlsFile = path.join(dataDirectory, 'urls.csv');
60155
fs.writeFileSync(urlsFile, 'URL,content_type,bytes,response\n');
61156
const urlsStream = fs.createWriteStream(urlsFile, { flags: 'a' });
62157

63158
scanner.on('urlFound', (url, contentType, bytes, statusCode) => {
64-
console.log('Crawled %s [%s] (%d bytes)', url, contentType, bytes);
159+
urlStates.set(url, { state: State.Pending });
65160
const csvLine = [
66161
JSON.stringify(url),
67162
contentType,
@@ -70,15 +165,28 @@ sade('lighthouse-parade <url> [dataDirectory]', true)
70165
].join(',');
71166
urlsStream.write(`${csvLine}\n`);
72167
});
168+
scanner.on('reportBegin', (url) => {
169+
urlStates.set(url, { state: State.ReportInProgress });
170+
});
171+
scanner.on('reportFail', (url, error) => {
172+
urlStates.set(url, { state: State.ReportComplete, error });
173+
log(printLine(url, State.ReportComplete, error));
174+
});
73175
scanner.on('reportComplete', (url, reportData) => {
74-
console.log('Report is done for', url);
176+
urlStates.set(url, { state: State.ReportComplete });
177+
log(printLine(url, State.ReportComplete));
75178
const reportFileName = makeFileNameFromUrl(url, 'csv');
76179

77180
fs.writeFileSync(path.join(reportsDirPath, reportFileName), reportData);
78181
});
79182

80183
scanner.on('info', (message) => {
81-
console.log(message);
184+
log(message);
185+
});
186+
187+
scanner.promise.then(() => {
188+
clearInterval(intervalId);
189+
console.log('DONE!');
82190
});
83191
}
84192
)

emitter.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const createEmitter = <Events extends EventMap, Resolve = never>() => {
2020

2121
const on = <E extends keyof Events>(eventName: E, handler: Events[E]) => {
2222
(eventHandlers[eventName] ??= [] as Events[E][]).push(handler);
23+
return emitter; // Allow chaining
2324
};
2425

2526
const promise = new Promise<Resolve>((resolve, reject) => {
@@ -28,13 +29,14 @@ export const createEmitter = <Events extends EventMap, Resolve = never>() => {
2829
});
2930
const eventHandlers: { [E in keyof Events]?: Events[E][] } = {};
3031
interface Emit {
31-
(eventName: 'resolve', value?: Resolve): void;
32-
(eventName: 'reject', value?: unknown): void;
3332
<E extends keyof Events>(
3433
eventName: E,
3534
...args: Parameters<Events[E]>
3635
): void;
36+
(eventName: 'resolve', value?: Resolve): void;
37+
(eventName: 'reject', value?: unknown): void;
3738
}
3839

39-
return { promise, on, emit };
40+
const emitter = { promise, on, emit };
41+
return emitter;
4042
};

lighthouse.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,76 @@
1-
import { spawnSync } from 'child_process';
1+
import { spawn } from 'child_process';
2+
import { createEmitter } from './emitter';
23

34
const lighthouseCli = require.resolve('lighthouse/lighthouse-cli');
45

6+
let lighthouseLimit = 2;
7+
let currentLighthouseInstances = 0;
8+
const lighthouseQueue: (() => void)[] = [];
9+
10+
const runLighthouseQueue = () => {
11+
while (
12+
lighthouseQueue.length > 0 &&
13+
currentLighthouseInstances < lighthouseLimit
14+
) {
15+
const run = lighthouseQueue.shift() as () => void;
16+
currentLighthouseInstances++;
17+
run();
18+
}
19+
};
20+
21+
type LighthouseEvents = {
22+
begin: () => void;
23+
complete: (reportData: string) => void;
24+
error: (message: Error) => void;
25+
};
26+
527
// This function is marked as async to wrap it with a promise to make error handling easier
628
// Even though the underlying process is spawned synchronously
729
// eslint-disable-next-line @cloudfour/typescript-eslint/require-await
8-
export const runLighthouseReport = async (url: string) => {
9-
const { status = -1, stdout } = spawnSync('node', [
10-
lighthouseCli,
11-
url,
12-
'--output=csv',
13-
'--output-path=stdout',
14-
'--emulated-form-factor=mobile',
15-
'--only-categories=performance',
16-
'--chrome-flags="--headless"',
17-
'--max-wait-for-load=45000',
18-
]);
19-
20-
if (status !== 0) {
21-
throw new Error(`Lighthouse report failed for: ${url}`);
22-
}
30+
export const runLighthouseReport = (url: string, maxConcurrency?: number) => {
31+
if (maxConcurrency) lighthouseLimit = maxConcurrency;
32+
const { on, emit } = createEmitter<LighthouseEvents>();
33+
const run = () => {
34+
emit('begin');
35+
const lighthouseProcess = spawn('node', [
36+
lighthouseCli,
37+
url,
38+
'--output=csv',
39+
'--output-path=stdout',
40+
'--emulated-form-factor=mobile',
41+
'--only-categories=performance',
42+
'--chrome-flags="--headless"',
43+
'--max-wait-for-load=45000',
44+
]);
45+
46+
let stdout = '';
47+
let stderr = '';
48+
49+
lighthouseProcess.stdout.on('data', (d) => {
50+
stdout += d;
51+
});
52+
53+
lighthouseProcess.stderr.on('data', (d) => {
54+
if (/runtime error encountered/i.test(d)) stderr += d;
55+
});
56+
57+
lighthouseProcess.on('close', (status) => {
58+
if (status === 0) {
59+
emit('complete', String(stdout).replace(/\r\n/g, '\n'));
60+
} else {
61+
emit(
62+
'error',
63+
new Error(stderr.trim() || `Lighthouse report failed for: ${url}`)
64+
);
65+
}
66+
67+
currentLighthouseInstances--;
68+
runLighthouseQueue();
69+
});
70+
};
71+
72+
lighthouseQueue.push(run);
73+
runLighthouseQueue();
2374

24-
return String(stdout).replace(/\r\n/g, '\n');
75+
return { on };
2576
};

0 commit comments

Comments
 (0)