@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 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
- : previous
106
- ? Number(JSON.parse(await readFile(values.previous, "utf8")).scan?.rotation ??
107
- -1) + 1
108
- : 0;
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 only: records and artifacts stay local.
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
- const platform = values.platform.replace(/\/+$/, "");
179
- const response = await fetch(`${platform}/api/command`, {
180
- method: "POST",
181
- headers: {
182
- "Content-Type": "application/json",
183
- Authorization: `Bearer ${key}`,
184
- },
185
- body: JSON.stringify({
186
- operation: "runs.push",
187
- args: {
188
- url: scan.url,
189
- scan,
190
- report: result.report,
191
- findings,
192
- pages,
193
- assets,
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");
@@ -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 browser aborts the page; the worker reopens a session.
108
- void browser.close();
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.slice(blockedBefore),
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;
@@ -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
  };
@@ -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
- const { openBrowserSession } = await import("./browser.js");
54
- if (browser && !browser.connected) {
55
- await browser.close();
56
- browser = undefined;
57
- }
58
- return (browser ??= await openBrowserSession());
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
- let job;
163
- while ((job = state.next(kinds))) {
164
- if (Date.now() > deadline || input.signal?.aborted) {
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 execute(job, kinds);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulldotdev/scan",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Whole-site website scanner: crawls every page, renders it, runs Lighthouse and axe, and reports every problem it finds.",
5
5
  "license": "MIT",
6
6
  "type": "module",