@fulldotdev/scan 0.2.0 → 0.3.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/cli.js +128 -29
- package/dist/engine/browser.js +16 -5
- package/dist/engine/proxy.js +2 -0
- package/dist/engine/run.js +53 -18
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -29,6 +29,8 @@ Options:
|
|
|
29
29
|
--push Push the result to the platform after the scan
|
|
30
30
|
--key <key> API key for --push (default: FULLSCAN_KEY)
|
|
31
31
|
--platform <url> Platform to push to (default: https://scan.full.dev)
|
|
32
|
+
--run <id> Fill this queued run of the platform with the result
|
|
33
|
+
--scheduled Store the result as a run of the nightly schedule
|
|
32
34
|
--json Print the result JSON to stdout instead of progress
|
|
33
35
|
--quiet No progress output
|
|
34
36
|
-h, --help Show this help
|
|
@@ -53,6 +55,8 @@ const { values, positionals } = parseArgs({
|
|
|
53
55
|
push: { type: "boolean", default: false },
|
|
54
56
|
key: { type: "string" },
|
|
55
57
|
platform: { type: "string", default: "https://scan.full.dev" },
|
|
58
|
+
run: { type: "string" },
|
|
59
|
+
scheduled: { type: "boolean", default: false },
|
|
56
60
|
json: { type: "boolean", default: false },
|
|
57
61
|
quiet: { type: "boolean", default: false },
|
|
58
62
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -80,7 +84,41 @@ const options = {
|
|
|
80
84
|
allowLocal: local,
|
|
81
85
|
...(values.pages ? { maxPages: Number(values.pages) } : {}),
|
|
82
86
|
};
|
|
87
|
+
const key = values.key ?? process.env.FULLSCAN_KEY;
|
|
88
|
+
const platform = values.platform.replace(/\/+$/, "");
|
|
89
|
+
// One call to the platform's typed API. Returns the result or throws.
|
|
90
|
+
async function callPlatform(path, operation, args) {
|
|
91
|
+
const response = await fetch(`${platform}/api/${path}`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Authorization: `Bearer ${key}`,
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ operation, args }),
|
|
98
|
+
});
|
|
99
|
+
const payload = (await response.json().catch(() => null));
|
|
100
|
+
if (!response.ok)
|
|
101
|
+
throw new Error(payload?.error?.message ?? `${response.status} ${response.statusText}`);
|
|
102
|
+
return payload?.result;
|
|
103
|
+
}
|
|
83
104
|
let previous;
|
|
105
|
+
let previousRotation;
|
|
106
|
+
// With a key and no file, the platform tells the scan what the site looked
|
|
107
|
+
// like last time, so changes are tracked on a runner just as well.
|
|
108
|
+
if (!values.previous && key) {
|
|
109
|
+
try {
|
|
110
|
+
const result = await callPlatform("query", "runs.previous", { url });
|
|
111
|
+
if (result) {
|
|
112
|
+
const { rotation: stored, ...rest } = result;
|
|
113
|
+
previous = rest;
|
|
114
|
+
if (typeof stored === "number")
|
|
115
|
+
previousRotation = stored;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
process.stderr.write(`Could not read the previous scan: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
84
122
|
if (values.previous) {
|
|
85
123
|
const file = JSON.parse(await readFile(values.previous, "utf8"));
|
|
86
124
|
previous = {
|
|
@@ -102,10 +140,12 @@ if (values.previous) {
|
|
|
102
140
|
}
|
|
103
141
|
const rotation = values.rotation
|
|
104
142
|
? Number(values.rotation)
|
|
105
|
-
:
|
|
106
|
-
?
|
|
107
|
-
|
|
108
|
-
|
|
143
|
+
: previousRotation !== undefined
|
|
144
|
+
? previousRotation
|
|
145
|
+
: values.previous
|
|
146
|
+
? Number(JSON.parse(await readFile(values.previous, "utf8")).scan?.rotation ??
|
|
147
|
+
-1) + 1
|
|
148
|
+
: 0;
|
|
109
149
|
const quiet = values.quiet || values.json;
|
|
110
150
|
const log = (line) => {
|
|
111
151
|
if (!quiet)
|
|
@@ -167,41 +207,100 @@ for (const artifact of state.artifacts.values()) {
|
|
|
167
207
|
};
|
|
168
208
|
}
|
|
169
209
|
await writeFile(join(out, "artifacts", "index.json"), JSON.stringify(index, null, 2));
|
|
170
|
-
// The push carries the result
|
|
210
|
+
// The push carries the result; the observations and the screenshots follow
|
|
211
|
+
// it in their own calls, so the run on the platform is as complete as one
|
|
212
|
+
// the platform ran itself.
|
|
171
213
|
let pushed;
|
|
172
214
|
if (values.push) {
|
|
173
|
-
const key = values.key ?? process.env.FULLSCAN_KEY;
|
|
174
215
|
if (!key) {
|
|
175
216
|
process.stderr.write("A key is required to push: pass --key or set FULLSCAN_KEY.\n");
|
|
176
217
|
process.exit(1);
|
|
177
218
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
});
|
|
197
|
-
const payload = (await response.json().catch(() => null));
|
|
198
|
-
if (!response.ok || !payload?.result) {
|
|
199
|
-
process.stderr.write(`Push failed: ${payload?.error?.message ?? `${response.status} ${response.statusText}`}\n`);
|
|
219
|
+
try {
|
|
220
|
+
pushed = await callPlatform("command", "runs.push", {
|
|
221
|
+
url: scan.url,
|
|
222
|
+
scan,
|
|
223
|
+
report: result.report,
|
|
224
|
+
findings,
|
|
225
|
+
pages,
|
|
226
|
+
assets,
|
|
227
|
+
...(values.run ? { runId: values.run } : {}),
|
|
228
|
+
...(values.scheduled ? { scheduled: true } : {}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
process.stderr.write(`Push failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
if (!pushed) {
|
|
236
|
+
process.stderr.write("Push failed: the platform stored no run.\n");
|
|
200
237
|
process.exit(1);
|
|
201
238
|
}
|
|
202
|
-
pushed = payload.result;
|
|
203
239
|
if (values.json)
|
|
204
240
|
process.stderr.write(`Pushed to ${platform}: site ${pushed.siteId}, run ${pushed.runId}\n`);
|
|
241
|
+
await pushRecords(pushed.runId);
|
|
242
|
+
await pushScreenshots(pushed.runId);
|
|
243
|
+
}
|
|
244
|
+
// Observations go up in batches that stay under 3 MB of JSON, because the
|
|
245
|
+
// platform's request limit is a few megabytes. A batch that fails costs the
|
|
246
|
+
// page detail of this run, never the run itself.
|
|
247
|
+
async function pushRecords(runId) {
|
|
248
|
+
const limit = 3 * 1024 * 1024;
|
|
249
|
+
const batches = [[]];
|
|
250
|
+
let size = 0;
|
|
251
|
+
for (const record of records) {
|
|
252
|
+
const bytes = Buffer.byteLength(JSON.stringify(record));
|
|
253
|
+
if (size + bytes > limit && batches[batches.length - 1].length) {
|
|
254
|
+
batches.push([]);
|
|
255
|
+
size = 0;
|
|
256
|
+
}
|
|
257
|
+
batches[batches.length - 1].push(record);
|
|
258
|
+
size += bytes;
|
|
259
|
+
}
|
|
260
|
+
for (const [index, batch] of batches.entries()) {
|
|
261
|
+
const final = index === batches.length - 1;
|
|
262
|
+
try {
|
|
263
|
+
await callPlatform("command", "runs.pushRecords", {
|
|
264
|
+
runId,
|
|
265
|
+
records: batch,
|
|
266
|
+
final,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
process.stderr.write(`Could not push ${batch.length} observations: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Screenshots are raw bytes, so they go to their own endpoint instead of
|
|
275
|
+
// swelling the JSON push by a third.
|
|
276
|
+
async function pushScreenshots(runId) {
|
|
277
|
+
for (const artifact of state.artifacts.values()) {
|
|
278
|
+
if (artifact.kind !== "screenshot")
|
|
279
|
+
continue;
|
|
280
|
+
try {
|
|
281
|
+
const query = new URLSearchParams({
|
|
282
|
+
runId,
|
|
283
|
+
kind: "screenshot",
|
|
284
|
+
key: artifact.key,
|
|
285
|
+
});
|
|
286
|
+
const response = await fetch(`${platform}/api/artifacts/upload?${query.toString()}`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: {
|
|
289
|
+
"Content-Type": artifact.contentType,
|
|
290
|
+
Authorization: `Bearer ${key}`,
|
|
291
|
+
},
|
|
292
|
+
body: new Uint8Array(artifact.body),
|
|
293
|
+
});
|
|
294
|
+
if (!response.ok) {
|
|
295
|
+
const payload = (await response.json().catch(() => null));
|
|
296
|
+
throw new Error(payload?.error?.message ??
|
|
297
|
+
`${response.status} ${response.statusText}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
process.stderr.write(`Could not upload the screenshot of ${artifact.key}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
205
304
|
}
|
|
206
305
|
if (values.json)
|
|
207
306
|
process.stdout.write(JSON.stringify(result) + "\n");
|
package/dist/engine/browser.js
CHANGED
|
@@ -82,8 +82,7 @@ const axeTags = [
|
|
|
82
82
|
export async function collectBrowser(scan, url, http, session) {
|
|
83
83
|
await resolvePublic(new URL(url).hostname);
|
|
84
84
|
const robots = await http.getRobots(url);
|
|
85
|
-
if (!robots.allowed ||
|
|
86
|
-
robots.parser?.isAllowed(url, "FulldevScan") === false)
|
|
85
|
+
if (!robots.allowed || robots.parser?.isAllowed(url, "FulldevScan") === false)
|
|
87
86
|
return {
|
|
88
87
|
observations: [
|
|
89
88
|
{
|
|
@@ -95,6 +94,9 @@ export async function collectBrowser(scan, url, http, session) {
|
|
|
95
94
|
};
|
|
96
95
|
const { proxy, browser, chromeVersion } = session;
|
|
97
96
|
const blockedBefore = proxy.blocked.length;
|
|
97
|
+
// Hosts this page asked for, so blocked requests of a tab rendering at
|
|
98
|
+
// the same time are not counted here.
|
|
99
|
+
const requestedHosts = new Set();
|
|
98
100
|
const result = { observations: [], artifacts: [], candidates: [] };
|
|
99
101
|
const errors = [];
|
|
100
102
|
let page;
|
|
@@ -104,8 +106,9 @@ export async function collectBrowser(scan, url, http, session) {
|
|
|
104
106
|
stage: "budget",
|
|
105
107
|
error: `Browser job exceeded ${budgetSeconds} seconds`,
|
|
106
108
|
});
|
|
107
|
-
// Closing the
|
|
108
|
-
|
|
109
|
+
// Closing the tab aborts this page and leaves its neighbours alone; a
|
|
110
|
+
// tab that will not close takes the browser with it.
|
|
111
|
+
void (page ? page.close().catch(() => browser.close()) : browser.close());
|
|
109
112
|
}, budgetSeconds * 1000);
|
|
110
113
|
watchdog.unref();
|
|
111
114
|
try {
|
|
@@ -136,6 +139,12 @@ export async function collectBrowser(scan, url, http, session) {
|
|
|
136
139
|
const cdp = await page.createCDPSession();
|
|
137
140
|
await cdp.send("Network.enable");
|
|
138
141
|
cdp.on("Network.requestWillBeSent", (e) => {
|
|
142
|
+
try {
|
|
143
|
+
requestedHosts.add(new URL(e.request?.url ?? "").hostname);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Not a URL with a host (data:, blob:).
|
|
147
|
+
}
|
|
139
148
|
if (staticTypes.test(e.type) && /^https?:/.test(e.request?.url ?? ""))
|
|
140
149
|
staticResources.add(e.request.url);
|
|
141
150
|
});
|
|
@@ -624,7 +633,9 @@ export async function collectBrowser(scan, url, http, session) {
|
|
|
624
633
|
data: {
|
|
625
634
|
status: errors.length ? "partial" : "completed",
|
|
626
635
|
errors,
|
|
627
|
-
blockedNetwork: proxy.blocked
|
|
636
|
+
blockedNetwork: proxy.blocked
|
|
637
|
+
.slice(blockedBefore)
|
|
638
|
+
.filter((entry) => [...requestedHosts].some((host) => entry.target.includes(host))),
|
|
628
639
|
},
|
|
629
640
|
});
|
|
630
641
|
return result;
|
package/dist/engine/proxy.js
CHANGED
|
@@ -7,6 +7,8 @@ export async function createEgressProxy() {
|
|
|
7
7
|
const sockets = new Set();
|
|
8
8
|
const remember = (socket) => {
|
|
9
9
|
sockets.add(socket);
|
|
10
|
+
// Several checks share a kept-alive socket; each adds its own listeners.
|
|
11
|
+
socket.setMaxListeners(50);
|
|
10
12
|
socket.on("close", () => sockets.delete(socket));
|
|
11
13
|
socket.setTimeout(30000, () => socket.destroy());
|
|
12
14
|
};
|
package/dist/engine/run.js
CHANGED
|
@@ -49,13 +49,23 @@ export async function runScanStep(input) {
|
|
|
49
49
|
const http = new HttpClient(options, undefined, undefined, session);
|
|
50
50
|
const deadline = input.budgetMs ? Date.now() + input.budgetMs : Infinity;
|
|
51
51
|
let browser;
|
|
52
|
+
let opening;
|
|
53
|
+
// One Chrome for the scan. Jobs that ask at the same moment share the
|
|
54
|
+
// launch instead of starting two browsers.
|
|
52
55
|
const browserSession = async () => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
if (browser?.connected)
|
|
57
|
+
return browser;
|
|
58
|
+
opening ??= (async () => {
|
|
59
|
+
const { openBrowserSession } = await import("./browser.js");
|
|
60
|
+
await browser?.close();
|
|
61
|
+
browser = await openBrowserSession();
|
|
62
|
+
opening = undefined;
|
|
63
|
+
return browser;
|
|
64
|
+
})().catch((error) => {
|
|
65
|
+
opening = undefined;
|
|
66
|
+
throw error;
|
|
67
|
+
});
|
|
68
|
+
return opening;
|
|
59
69
|
};
|
|
60
70
|
const run = async (job) => {
|
|
61
71
|
switch (job.kind) {
|
|
@@ -147,28 +157,53 @@ export async function runScanStep(input) {
|
|
|
147
157
|
pending: state.pending(kinds),
|
|
148
158
|
});
|
|
149
159
|
}
|
|
150
|
-
// Chromium and Lighthouse leave memory behind; a fresh browser for the
|
|
151
|
-
// next page is cheaper than the process growing without bound.
|
|
152
|
-
if (["browser", "lighthouse"].includes(job.kind) &&
|
|
153
|
-
browser &&
|
|
154
|
-
process.memoryUsage().rss > 1200 * 1024 * 1024) {
|
|
155
|
-
await browser.close();
|
|
156
|
-
browser = undefined;
|
|
157
|
-
}
|
|
158
160
|
};
|
|
161
|
+
// How many jobs of a kind run at once. Link and file checks mostly wait on
|
|
162
|
+
// the network; two pages render side by side as tabs of one Chrome. Pages
|
|
163
|
+
// of the scanned site are fetched one at a time so the site sets the pace,
|
|
164
|
+
// and Lighthouse runs alone because a busy CPU worsens its scores.
|
|
165
|
+
const concurrency = {
|
|
166
|
+
resource: 6,
|
|
167
|
+
browser: 2,
|
|
168
|
+
};
|
|
169
|
+
const browserKinds = ["browser", "lighthouse"];
|
|
159
170
|
let lastCheckpoint = Date.now();
|
|
160
171
|
// True when the budget ran out and the scan should pause here.
|
|
161
172
|
const drain = async (kinds) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
173
|
+
const inFlight = new Map();
|
|
174
|
+
const running = (list) => [...inFlight.keys()].filter((j) => list.includes(j.kind)).length;
|
|
175
|
+
let recycle = false;
|
|
176
|
+
for (;;) {
|
|
177
|
+
const stopping = Date.now() > deadline || !!input.signal?.aborted;
|
|
178
|
+
let job;
|
|
179
|
+
while (!stopping &&
|
|
180
|
+
(job = state.next(kinds.filter((kind) => running([kind]) < (concurrency[kind] ?? 1) &&
|
|
181
|
+
// No new tab while Chrome waits to be replaced.
|
|
182
|
+
!(recycle && browserKinds.includes(kind)))))) {
|
|
183
|
+
const current = job;
|
|
184
|
+
current.status = "running";
|
|
185
|
+
inFlight.set(current, execute(current, kinds).finally(() => inFlight.delete(current)));
|
|
186
|
+
}
|
|
187
|
+
if (!inFlight.size) {
|
|
188
|
+
if (!stopping || !state.pending(kinds))
|
|
189
|
+
break;
|
|
165
190
|
if (input.pauseAtBudget)
|
|
166
191
|
return true;
|
|
167
192
|
state.cancelQueued();
|
|
168
193
|
scan.coverage.expired = true;
|
|
169
194
|
break;
|
|
170
195
|
}
|
|
171
|
-
await
|
|
196
|
+
await Promise.race(inFlight.values());
|
|
197
|
+
// Chromium and Lighthouse leave memory behind; a fresh browser is
|
|
198
|
+
// cheaper than the process growing without bound. It is replaced once
|
|
199
|
+
// no tab is open.
|
|
200
|
+
if (browser && process.memoryUsage().rss > 1200 * 1024 * 1024)
|
|
201
|
+
recycle = true;
|
|
202
|
+
if (recycle && !running(browserKinds)) {
|
|
203
|
+
await browser?.close();
|
|
204
|
+
browser = undefined;
|
|
205
|
+
recycle = false;
|
|
206
|
+
}
|
|
172
207
|
if (input.onCheckpoint &&
|
|
173
208
|
input.checkpointEveryMs &&
|
|
174
209
|
Date.now() - lastCheckpoint > input.checkpointEveryMs) {
|
package/package.json
CHANGED