imgtune 0.1.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/CHANGELOG.md +172 -0
- package/README.md +109 -5
- package/dist/animated.js +176 -0
- package/dist/audit.js +1 -0
- package/dist/backgrounds.js +42 -7
- package/dist/broken.js +5 -1
- package/dist/browser.js +4 -4
- package/dist/candidates.js +25 -0
- package/dist/check-report.js +56 -6
- package/dist/check.js +26 -5
- package/dist/cli.js +86 -8
- package/dist/egress-proxy.js +2 -1
- package/dist/fetch.js +9 -4
- package/dist/ffmpeg.js +530 -0
- package/dist/gpu.js +12 -0
- package/dist/hints.js +704 -16
- package/dist/image-file.js +154 -34
- package/dist/image-open.js +9 -0
- package/dist/images.js +142 -8
- package/dist/layout.js +7 -7
- package/dist/lcp.js +2 -1
- package/dist/locate-join.js +86 -0
- package/dist/locate-only.js +14 -3
- package/dist/locate.js +6 -2
- package/dist/measure.js +39 -1
- package/dist/media-encode.js +1054 -0
- package/dist/media-file.js +284 -0
- package/dist/media-limits.js +31 -0
- package/dist/media-model.js +251 -0
- package/dist/media-phase.js +675 -0
- package/dist/media-prepare.js +31 -0
- package/dist/px.js +11 -0
- package/dist/recheck.js +12 -4
- package/dist/remote.js +1065 -107
- package/dist/replay.js +20 -0
- package/dist/resources/audio.js +248 -0
- package/dist/resources/brotli.js +22 -0
- package/dist/resources/cache.js +59 -0
- package/dist/resources/capture.js +225 -0
- package/dist/resources/compression.js +122 -0
- package/dist/resources/favicon.js +130 -0
- package/dist/resources/font.js +440 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/lottie.js +491 -0
- package/dist/resources/model.js +478 -0
- package/dist/resources/phase.js +171 -0
- package/dist/resources/pool.js +47 -0
- package/dist/resources/report.js +57 -0
- package/dist/resources/sfnt.js +305 -0
- package/dist/resources/svg.js +178 -0
- package/dist/resources/types.js +1 -0
- package/dist/resources/zip.js +67 -0
- package/dist/result-cache.js +56 -3
- package/dist/run.js +32 -8
- package/dist/sizes.js +7 -5
- package/dist/summary.js +69 -3
- package/dist/vector-art.js +294 -0
- package/dist/vector-phase.js +222 -0
- package/dist/video-analysis.js +371 -0
- package/dist/webp-animation.js +283 -0
- package/package.json +25 -4
- package/skills/imgtune/SKILL.md +8 -4
- package/skills/imgtune/reference.md +1 -1
package/dist/browser.js
CHANGED
|
@@ -14,11 +14,11 @@ export function mapLaunchError(err) {
|
|
|
14
14
|
return /Executable doesn['’]t exist/.test(text) ? new ChromiumMissingError() : err;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* Chromium flags for measuring. `--disable-
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Chromium flags for measuring. `--disable-gpu` keeps the GPU process out. The software rasterizer (SwiftShader) is left
|
|
18
|
+
* on: with `--disable-software-rasterizer` WebGL is off, and a page with a 3D viewer never requests its model (a census
|
|
19
|
+
* site made 5 model requests with the default flags and none without). Only WebGL pages pay the extra CPU for it.
|
|
20
20
|
*/
|
|
21
|
-
export const CHROMIUM_ARGS = ["--disable-gpu"
|
|
21
|
+
export const CHROMIUM_ARGS = ["--disable-gpu"];
|
|
22
22
|
/** The headless Chromium every measuring command uses. */
|
|
23
23
|
export async function launchChromium() {
|
|
24
24
|
try {
|
package/dist/candidates.js
CHANGED
|
@@ -13,6 +13,31 @@ export function rightCandidate(candidates, needed) {
|
|
|
13
13
|
const sorted = [...candidates].sort((a, b) => a.width - b.width);
|
|
14
14
|
return sorted.find((c) => c.width >= needed) ?? sorted[sorted.length - 1];
|
|
15
15
|
}
|
|
16
|
+
/** The device-pixel step a plain image's variant widths are rounded up to. */
|
|
17
|
+
export const SRCSET_WIDTH_STEP = 10;
|
|
18
|
+
/** A plain image gets at most this many variant widths, one file each. */
|
|
19
|
+
export const SRCSET_MAX_WIDTHS = 3;
|
|
20
|
+
/** The widths of a srcset as printed: "190w, 270w, 380w". */
|
|
21
|
+
export function srcsetWidthLabel(widths) {
|
|
22
|
+
return widths.map((w) => `${w}w`).join(", ");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The widths the variants of a plain image (no srcset today) need: each device-pixel need rounded up to SRCSET_WIDTH_STEP
|
|
26
|
+
* and capped at the file's own width. More than SRCSET_MAX_WIDTHS are merged by joining the closest pair and keeping the
|
|
27
|
+
* larger, so every need is still covered by a file at least as wide. Sorted ascending; empty when no need is known.
|
|
28
|
+
*/
|
|
29
|
+
export function srcsetWidthsOf(needs, naturalWidth) {
|
|
30
|
+
const cap = naturalWidth !== undefined && naturalWidth > 0 ? naturalWidth : Infinity;
|
|
31
|
+
const widths = [...new Set(needs.filter((n) => n > 0).map((n) => Math.min(Math.ceil(n / SRCSET_WIDTH_STEP) * SRCSET_WIDTH_STEP, cap)))].sort((a, b) => a - b);
|
|
32
|
+
while (widths.length > SRCSET_MAX_WIDTHS) {
|
|
33
|
+
let at = 0;
|
|
34
|
+
for (let i = 1; i < widths.length - 1; i++)
|
|
35
|
+
if (widths[i + 1] - widths[i] < widths[at + 1] - widths[at])
|
|
36
|
+
at = i;
|
|
37
|
+
widths.splice(at, 1);
|
|
38
|
+
}
|
|
39
|
+
return widths;
|
|
40
|
+
}
|
|
16
41
|
/** The served file is undersized: its pixels are below 0.9 x need, and a larger candidate exists. */
|
|
17
42
|
export function isUndersized(servedWidth, needed, largest) {
|
|
18
43
|
return servedWidth < UNDERSIZED_RATIO * needed && servedWidth < largest;
|
package/dist/check-report.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// The Markdown report of `imgtune check`: a pure builder over the survey results, the deep results, the route grouping and
|
|
2
2
|
// the deep selection. No browser, no files. The audit mode extends it with source-aware sections; the sections below keep
|
|
3
3
|
// their order so the two stay comparable. Page-derived text goes through mdText and mdCode (see text.ts).
|
|
4
|
-
import { hintsOf } from "./hints.js";
|
|
4
|
+
import { groupSrcsetOf, hiddenPagesLine, hiddenVideoSummaryLine, hintsOf, videoRowOf, videoSavingOf, videoTotalsLine } from "./hints.js";
|
|
5
|
+
import { srcsetWidthLabel } from "./candidates.js";
|
|
5
6
|
import { lcpLinesOf } from "./lcp.js";
|
|
6
7
|
import { planQualityCap, qualitiesLine } from "./qualities-cap.js";
|
|
7
8
|
import { hasFix } from "./images.js";
|
|
8
9
|
import { APPROX_MARK, siteNumbers } from "./remote.js";
|
|
9
|
-
import { errorLine, mdCode, mdText } from "./text.js";
|
|
10
|
+
import { errorLine, mdCode, mdText, safeText } from "./text.js";
|
|
11
|
+
import { resourceTotalsLine } from "./resources/report.js";
|
|
10
12
|
/** How many fixes, heavy pages, sharpness images and survey route types the report lists in full. */
|
|
11
13
|
export const REPORT_FIXES = 15;
|
|
12
14
|
export const REPORT_SHARPNESS = 15;
|
|
@@ -66,6 +68,7 @@ export function fixGroupsOf(deep, qualities) {
|
|
|
66
68
|
const pages = [...new Set(group.hints.map((h) => h.page))];
|
|
67
69
|
const saved = group.hints.reduce((sum, h) => sum + h.bytes.saved, 0) / 1024;
|
|
68
70
|
const sizes = group.hints.reduce((sum, h) => sum + (h.bytes.sizes ?? 0), 0) / 1024;
|
|
71
|
+
const resize = group.hints.reduce((sum, h) => sum + (h.bytes.resize ?? 0), 0) / 1024;
|
|
69
72
|
const sharp = group.hints.reduce((sum, h) => sum + h.bytes.addedForSharpness, 0) / 1024;
|
|
70
73
|
const targets = group.hints.map((h) => h.quality?.target).filter((t) => t !== undefined);
|
|
71
74
|
const quality = group.hints.find((h) => h.quality !== undefined)?.quality;
|
|
@@ -82,6 +85,7 @@ export function fixGroupsOf(deep, qualities) {
|
|
|
82
85
|
current,
|
|
83
86
|
proposed,
|
|
84
87
|
basis: first.sizes.basis,
|
|
88
|
+
srcset: groupSrcsetOf(group),
|
|
85
89
|
format: best !== undefined && best !== first.format?.current ? best : undefined,
|
|
86
90
|
...(quality !== undefined
|
|
87
91
|
? {
|
|
@@ -99,7 +103,8 @@ export function fixGroupsOf(deep, qualities) {
|
|
|
99
103
|
savedKBSum: saved,
|
|
100
104
|
savedKBPerPage: perPage(saved),
|
|
101
105
|
sizesKBPerPage: perPage(sizes),
|
|
102
|
-
|
|
106
|
+
resizeKBPerPage: perPage(resize),
|
|
107
|
+
qualityKBPerPage: perPage(saved - sizes - resize),
|
|
103
108
|
sharpnessKBPerPage: perPage(sharp),
|
|
104
109
|
});
|
|
105
110
|
}
|
|
@@ -108,7 +113,11 @@ export function fixGroupsOf(deep, qualities) {
|
|
|
108
113
|
/** The line of one fix: where, the sizes and quality change, the saving per view and its pages. */
|
|
109
114
|
function fixLines(fix, rank) {
|
|
110
115
|
const where = fix.located ? mdCode(fix.where) : `${mdCode(fix.where)} (not located)`;
|
|
111
|
-
const sizes = fix.
|
|
116
|
+
const sizes = fix.srcset !== undefined
|
|
117
|
+
? `no srcset -> serve ${srcsetWidthLabel(fix.srcset.widths)} + srcset/sizes`
|
|
118
|
+
: fix.current === fix.proposed
|
|
119
|
+
? "sizes unchanged"
|
|
120
|
+
: `sizes ${mdCode(fix.current)} -> ${mdCode(fix.proposed)}${fix.basis ? ` (${mdText(fix.basis)})` : ""}`;
|
|
112
121
|
const quality = fix.quality === undefined
|
|
113
122
|
? "quality -"
|
|
114
123
|
: fix.quality.target === undefined
|
|
@@ -116,7 +125,8 @@ function fixLines(fix, rank) {
|
|
|
116
125
|
: fix.quality.void
|
|
117
126
|
? `quality ${fix.quality.current} -> ${fix.quality.target} (snapped: void, counted as 0)`
|
|
118
127
|
: `quality ${fix.quality.current} -> ${fix.quality.target}${fix.quality.snapFrom !== undefined ? ` (snapped from ${fix.quality.snapFrom}${fix.quality.allowed ? `, allowed ${fix.quality.allowed.join(",")}` : ""})` : ""}`;
|
|
119
|
-
const
|
|
128
|
+
const split = fix.resizeKBPerPage > 0 ? `sizes ${kb(fix.sizesKBPerPage)} + resize ${kb(fix.resizeKBPerPage)} + quality ${kb(fix.qualityKBPerPage)}` : `sizes ${kb(fix.sizesKBPerPage)} + quality ${kb(fix.qualityKBPerPage)}`;
|
|
129
|
+
const saving = `saves ${kb(fix.savedKBPerPage)} KB/view per page (${split})`;
|
|
120
130
|
const source = fix.pattern !== undefined ? `, src ${mdCode(fix.pattern)}` : "";
|
|
121
131
|
const where2 = `${fix.pages.length} page(s), ${fix.images} image(s), loader ${mdText(fix.loader)}${source}`;
|
|
122
132
|
return [
|
|
@@ -153,6 +163,13 @@ export function buildCheckReport(input) {
|
|
|
153
163
|
const notMeasured = (pages) => pages.length - measured(pages).length - count(pages, "broken") - count(pages, "failed");
|
|
154
164
|
out(`survey ${input.urls.surveyed} page(s): ${survey.length} ok, ${count(input.survey, "broken")} broken, ${count(input.survey, "failed")} failed, ${notMeasured(input.survey)} not measured · deep ${input.deep.length} page(s)${retried > 0 ? ` (${retried} retried)` : ""}: ${deepOk.length} ok, ${count(input.deep, "failed")} failed, ${notMeasured(input.deep)} not measured`);
|
|
155
165
|
out(`time: survey ${input.timings.survey.toFixed(1)} s, deep ${input.timings.deep.toFixed(1)} s, total ${(input.timings.survey + input.timings.deep).toFixed(1)} s`, "");
|
|
166
|
+
// The media phase's note is printed once, at the top (the same note is on each page that has media).
|
|
167
|
+
const media = [...input.survey, ...input.deep].find((p) => p.mediaNote !== undefined)?.mediaNote;
|
|
168
|
+
if (media !== undefined)
|
|
169
|
+
out(mdText(media), "");
|
|
170
|
+
const vectorNote = input.deep.find((p) => p.vectorNote !== undefined)?.vectorNote;
|
|
171
|
+
if (vectorNote !== undefined)
|
|
172
|
+
out(mdText(vectorNote), "");
|
|
156
173
|
if (input.stop !== undefined) {
|
|
157
174
|
out("## Stopped before the deep phase", "", `${mdText(input.stop.reason)}`, `Fix: ${mdText(input.stop.fix)}`, "");
|
|
158
175
|
}
|
|
@@ -170,6 +187,9 @@ export function buildCheckReport(input) {
|
|
|
170
187
|
const exactEst = mean(deepTotals.map((t) => t.exactEstKB));
|
|
171
188
|
const numbers = siteNumbers(deepOk.map((p) => p.page));
|
|
172
189
|
out(`- Deep (${deepOk.length} page(s), viewports ${input.options.deepViewports.map(mdText).join(", ")}): mean image ${kb(numbers.meanImageBytes / 1024)} KB/view.`, ` - exact: ${kb(exact)} KB/view (${pct(numbers.exactPct)})`, ` - exact + est.: ${APPROX_MARK.trim()} ${kb(exactEst)} KB/view (${pct(numbers.exactEstPct)})${approx ? " (some estimates are approximate)" : ""}`, ` - sharpness: +${kb(mean(deepTotals.map((t) => t.sharpnessKB)))} KB/view, a separate number: it is never netted against the saving`);
|
|
190
|
+
const hiddenLine = hiddenPagesLine(deepOk);
|
|
191
|
+
if (hiddenLine !== undefined)
|
|
192
|
+
out(` - ${mdText(hiddenLine)}`);
|
|
173
193
|
// The LCP image is the one the first paint waits for: its line, per page, before the ranked fixes.
|
|
174
194
|
for (const page of deepOk) {
|
|
175
195
|
for (const line of lcpLinesOf(page, mdCode))
|
|
@@ -192,6 +212,34 @@ export function buildCheckReport(input) {
|
|
|
192
212
|
out(`Merged across the deep pages: one file:line when located, else one image pattern with the same proposed sizes. Ranked by the total saving over the pages. Top ${Math.min(REPORT_FIXES, fixes.length)} of ${fixes.length}.`, "");
|
|
193
213
|
fixes.slice(0, REPORT_FIXES).forEach((fix, i) => out(...fixLines(fix, i + 1), ""));
|
|
194
214
|
}
|
|
215
|
+
// 3b. Video: the self-hosted <video> files of the deep pages, per view and per play, largest saveable first.
|
|
216
|
+
const videos = deepOk.flatMap((p) => (p.videos ?? []).map((video) => ({ page: p.url, video })));
|
|
217
|
+
if (videos.length > 0) {
|
|
218
|
+
out("## Video", "");
|
|
219
|
+
out(`${videoTotalsLine(videos.map((v) => v.video))} (${videos.length} video(s) on ${deepOk.filter((p) => (p.videos ?? []).length > 0).length} deep page(s))`, "");
|
|
220
|
+
const hiddenVideos = hiddenVideoSummaryLine(videos.map((v) => v.video));
|
|
221
|
+
if (hiddenVideos !== undefined)
|
|
222
|
+
out(`- ${mdText(hiddenVideos)}`);
|
|
223
|
+
const ranked = [...videos].sort((a, b) => videoSavingOf(b.video).view - videoSavingOf(a.video).view || a.page.localeCompare(b.page));
|
|
224
|
+
for (const row of ranked.slice(0, REPORT_FIXES)) {
|
|
225
|
+
out(`- ${mdCode(videoRowOf(row.video, (text) => JSON.stringify(safeText(text))))} on ${mdCode(pathOf(row.page))}`);
|
|
226
|
+
}
|
|
227
|
+
out("");
|
|
228
|
+
}
|
|
229
|
+
// 3c. Resources: the deep pages' non-image findings (compression, favicons), largest saving first (see resources/).
|
|
230
|
+
const resourceRows = deepOk.flatMap((p) => (p.resources ?? []).map((finding) => ({ page: p.url, finding })));
|
|
231
|
+
if (resourceRows.length > 0) {
|
|
232
|
+
out("## Resources", "");
|
|
233
|
+
out(mdText(resourceTotalsLine(resourceRows.map((r) => r.finding)) ?? ""), "");
|
|
234
|
+
const ranked = [...resourceRows].sort((a, b) => b.finding.savedBytes - a.finding.savedBytes || a.page.localeCompare(b.page));
|
|
235
|
+
for (const row of ranked.slice(0, REPORT_FIXES)) {
|
|
236
|
+
const saved = row.finding.savedBytes > 0 ? `, saves ${kb(row.finding.savedBytes / 1024)} KB/view${row.finding.estimate ? " (est.)" : ""}` : "";
|
|
237
|
+
out(`- ${mdText(row.finding.kind)}: ${mdText(row.finding.title)}${saved} on ${mdCode(pathOf(row.page))}`);
|
|
238
|
+
if (row.finding.fix[0] !== undefined)
|
|
239
|
+
out(` - fix: ${mdText(row.finding.fix[0])}`);
|
|
240
|
+
}
|
|
241
|
+
out("");
|
|
242
|
+
}
|
|
195
243
|
// 4. Heavy but not wasteful.
|
|
196
244
|
out("## Heavy but not wasteful", "");
|
|
197
245
|
const heavyCut = percentile(deepTotals.map((t) => t.kb), 0.75);
|
|
@@ -280,6 +328,8 @@ export function buildCheckReport(input) {
|
|
|
280
328
|
? `- Route types group URLs by template (locale and device variant stripped), as check does; the route file column names the file(s) each type renders from. Only the heaviest page of each type is deep-scanned; ${input.selection.leftOut.length} type(s) were not (see the table).`
|
|
281
329
|
: `- Route types group URLs by template (locale and device variant stripped). Only the heaviest page of each type is deep-scanned; ${input.selection.leftOut.length} type(s) were not (see the table).`, input.options.qualities !== undefined
|
|
282
330
|
? `- Quality targets are snapped up to the allowed qualities ${input.options.qualities.join(", ")}; a snapped target's quality saving is void and counts as 0.${input.qualitiesNote !== undefined ? ` (${mdText(input.qualitiesNote)})` : ""}`
|
|
283
|
-
: "- Quality targets are not snapped (no --qualities given): a target the framework cannot serve is not the saving it looks like.", `- Third-party requests: ${input.options.thirdParty}${input.options.allowHosts ? ` (hosts kept: ${input.options.allowHosts.map(mdText).join(", ")})` : ""}. Blocking can drop images a page loads through its own API on another host.`, `- Pages with images still loading at settle show the images that loaded; the rest are not measured.`,
|
|
331
|
+
: "- Quality targets are not snapped (no --qualities given): a target the framework cannot serve is not the saving it looks like.", `- Third-party requests: ${input.options.thirdParty}${input.options.allowHosts ? ` (hosts kept: ${input.options.allowHosts.map(mdText).join(", ")})` : ""}. Blocking can drop images a page loads through its own API on another host.`, `- Pages with images still loading at settle show the images that loaded; the rest are not measured.`, ...(videos.length > 0
|
|
332
|
+
? [`- Video bytes: per view as the page loads them (autoplay: the whole file). The saveable bytes come from 2 s sample encodes: est.`]
|
|
333
|
+
: []), input.options.gpu !== undefined ? `- Quality was scored on the GPU ${input.options.gpu} (with the CPU fallback noted in the page JSON).` : "- Quality was scored on the CPU.", "");
|
|
284
334
|
return `${lines.join("\n")}\n`;
|
|
285
335
|
}
|
package/dist/check.js
CHANGED
|
@@ -7,11 +7,11 @@ import { mkdir, readFile } from "node:fs/promises";
|
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { DEFAULT_VIEWPORTS } from "./config.js";
|
|
9
9
|
import { writeFileAtomic } from "./atomic.js";
|
|
10
|
-
import {
|
|
10
|
+
import { gpuSettingOf, gpuSettingOne } from "./gpu.js";
|
|
11
11
|
import { parseQualities } from "./hints.js";
|
|
12
12
|
import { BROKEN_EXIT, surveyStop } from "./broken.js";
|
|
13
13
|
import { DEFAULT_THIRD_PARTY, parseAllowHosts, thirdPartyFromFlags } from "./third-party.js";
|
|
14
|
-
import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, parseLimit, parseRange, readStorageState, runScan, scanPlan, } from "./remote.js";
|
|
14
|
+
import { MAX_IDLE_TIMEOUT_MS, MAX_PAGE_CONCURRENCY, MAX_RESOURCE_TIME, MAX_SVG_BUDGET, parseLimit, parseRange, readStorageState, runScan, scanPlan, } from "./remote.js";
|
|
15
15
|
import { restrictRoutes, groupRoutes, parseRouteMap } from "./routes.js";
|
|
16
16
|
import { sampleByPattern, urlsFromSource } from "./urls.js";
|
|
17
17
|
import { deepRetries, selectDeepPages } from "./deep.js";
|
|
@@ -36,6 +36,10 @@ export function parseCheckFlags(argv) {
|
|
|
36
36
|
const arg = argv[i];
|
|
37
37
|
if (arg === "--no-cache")
|
|
38
38
|
args.noCache = true;
|
|
39
|
+
else if (arg === "--gpu-encode")
|
|
40
|
+
args.gpuEncode = true;
|
|
41
|
+
else if (arg === "--no-resources")
|
|
42
|
+
args.noResources = true;
|
|
39
43
|
else if (arg === "--ignore-robots")
|
|
40
44
|
args.ignoreRobots = true;
|
|
41
45
|
else if (arg === "--block-third-party")
|
|
@@ -44,9 +48,9 @@ export function parseCheckFlags(argv) {
|
|
|
44
48
|
else if (arg === "--allow-third-party")
|
|
45
49
|
continue;
|
|
46
50
|
else if (arg === "--gpu")
|
|
47
|
-
args.gpu ??=
|
|
51
|
+
args.gpu ??= gpuSettingOne(0);
|
|
48
52
|
else if (arg === "--gpu-device") {
|
|
49
|
-
args.gpu =
|
|
53
|
+
args.gpu = gpuSettingOf(valueOf(argv, i, arg));
|
|
50
54
|
i++;
|
|
51
55
|
}
|
|
52
56
|
else if (arg === "--urls-from" ||
|
|
@@ -57,6 +61,8 @@ export function parseCheckFlags(argv) {
|
|
|
57
61
|
arg === "--page-concurrency" ||
|
|
58
62
|
arg === "--qualities" ||
|
|
59
63
|
arg === "--idle-timeout" ||
|
|
64
|
+
arg === "--svg-budget" ||
|
|
65
|
+
arg === "--resource-time" ||
|
|
60
66
|
arg === "--storage-state" ||
|
|
61
67
|
arg === "--allow-host") {
|
|
62
68
|
const value = valueOf(argv, i, arg);
|
|
@@ -77,6 +83,10 @@ export function parseCheckFlags(argv) {
|
|
|
77
83
|
args.qualities = parseQualities(value);
|
|
78
84
|
else if (arg === "--idle-timeout")
|
|
79
85
|
args.idleTimeoutMs = parseRange(arg, value, 0, MAX_IDLE_TIMEOUT_MS);
|
|
86
|
+
else if (arg === "--svg-budget")
|
|
87
|
+
args.svgBudget = parseRange(arg, value, 0, MAX_SVG_BUDGET);
|
|
88
|
+
else if (arg === "--resource-time")
|
|
89
|
+
args.resourceTime = parseRange(arg, value, 0, MAX_RESOURCE_TIME);
|
|
80
90
|
else if (arg === "--storage-state")
|
|
81
91
|
args.storageState = value;
|
|
82
92
|
else
|
|
@@ -182,6 +192,7 @@ export async function scanCheckPages(input) {
|
|
|
182
192
|
ignoreRobots: settings.ignoreRobots,
|
|
183
193
|
aggressive: false,
|
|
184
194
|
thirdParty: settings.thirdParty,
|
|
195
|
+
resources: settings.noResources !== true,
|
|
185
196
|
};
|
|
186
197
|
const base = { urls: [], out: outRoot, aggressive: false, quality: true, thirdParty: settings.thirdParty };
|
|
187
198
|
const surveyPlan = scanPlan({ ...base, survey: true });
|
|
@@ -203,6 +214,9 @@ export async function scanCheckPages(input) {
|
|
|
203
214
|
shellWord(surveyDir),
|
|
204
215
|
...(settings.thirdParty.block ? ["--block-third-party"] : []),
|
|
205
216
|
...(settings.noCache ? ["--no-cache"] : []),
|
|
217
|
+
...(settings.gpuEncode ? ["--gpu-encode"] : []),
|
|
218
|
+
...(settings.noResources ? ["--no-resources"] : []),
|
|
219
|
+
...(settings.resourceTime !== undefined ? ["--resource-time", String(settings.resourceTime)] : []),
|
|
206
220
|
].join(" "),
|
|
207
221
|
});
|
|
208
222
|
log(`survey: ${urls.length} URL(s), ${routes.types.length} route type(s)`);
|
|
@@ -236,13 +250,17 @@ export async function scanCheckPages(input) {
|
|
|
236
250
|
if (page.status === "ok")
|
|
237
251
|
expected.set(page.url, page.counts.images);
|
|
238
252
|
const flags = [
|
|
239
|
-
...(settings.gpu !== undefined ? ["--gpu-device",
|
|
253
|
+
...(settings.gpu !== undefined ? ["--gpu-device", settings.gpu.devices.join(",")] : []),
|
|
240
254
|
...(settings.thirdParty.block ? ["--block-third-party"] : []),
|
|
241
255
|
...(settings.thirdParty.allowHosts.length > 0 ? ["--allow-host", shellWord(settings.thirdParty.allowHosts.join(","))] : []),
|
|
242
256
|
...(settings.noCache ? ["--no-cache"] : []),
|
|
257
|
+
...(settings.gpuEncode ? ["--gpu-encode"] : []),
|
|
258
|
+
...(settings.noResources ? ["--no-resources"] : []),
|
|
259
|
+
...(settings.resourceTime !== undefined ? ["--resource-time", String(settings.resourceTime)] : []),
|
|
243
260
|
...(settings.ignoreRobots ? ["--ignore-robots"] : []),
|
|
244
261
|
...(settings.pageConcurrency !== undefined ? ["--page-concurrency", String(settings.pageConcurrency)] : []),
|
|
245
262
|
...(settings.idleTimeoutMs !== undefined ? ["--idle-timeout", String(settings.idleTimeoutMs)] : []),
|
|
263
|
+
...(settings.svgBudget !== undefined ? ["--svg-budget", String(settings.svgBudget)] : []),
|
|
246
264
|
];
|
|
247
265
|
commands.push({ phase: "deep", command: ["imgtune scan", ...deepUrls.map(shellWord), "--out", shellWord(deepDir), ...flags].join(" ") });
|
|
248
266
|
log(`deep: ${deepUrls.length} page(s), full scan`);
|
|
@@ -254,6 +272,7 @@ export async function scanCheckPages(input) {
|
|
|
254
272
|
pageConcurrency: fullPlan.pageConcurrency,
|
|
255
273
|
idleTimeoutMs: fullPlan.idleTimeoutMs,
|
|
256
274
|
gpu: settings.gpu,
|
|
275
|
+
svgBudget: settings.svgBudget,
|
|
257
276
|
expectImages: (url) => expected.get(url),
|
|
258
277
|
};
|
|
259
278
|
const deepStart = performance.now();
|
|
@@ -342,6 +361,8 @@ export async function finishCheck(input) {
|
|
|
342
361
|
pageConcurrency: settings.pageConcurrency,
|
|
343
362
|
idleTimeoutMs: settings.idleTimeoutMs,
|
|
344
363
|
noCache: settings.noCache,
|
|
364
|
+
...(settings.noResources ? { noResources: true } : {}),
|
|
365
|
+
...(settings.resourceTime !== undefined ? { resourceTime: settings.resourceTime } : {}),
|
|
345
366
|
ignoreRobots: settings.ignoreRobots,
|
|
346
367
|
storageState: input.storageState,
|
|
347
368
|
thirdParty: settings.thirdParty.block ? "blocked" : "allowed",
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,12 @@ const progress = (message) => {
|
|
|
21
21
|
const OPTION_HELP = {
|
|
22
22
|
out: ["--out <dir>", "directory for the JSON files (default: .imgtune/scan)"],
|
|
23
23
|
noCache: ["--no-cache", "do not read or write the result cache in .imgtune/cache (the numbers are computed again)"],
|
|
24
|
+
noResources: ["--no-resources", "skip the resource phase: no capture of the non-image responses, so none of the resource findings (compression, favicons, fonts, SVG, Lottie, 3D, audio)"],
|
|
25
|
+
resourceTime: ["--resource-time <s>", "time limit of the heavy resource work (3D, fonts, SVG, audio, lottie re-encodes), in seconds, 0 to 3600 (default 60); items over it are reported as not measured"],
|
|
26
|
+
gpuEncode: [
|
|
27
|
+
"--gpu-encode",
|
|
28
|
+
"EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF); off by default (CPU encodes). Bytes are scaled to a CPU encode (÷1.4)",
|
|
29
|
+
],
|
|
24
30
|
root: [
|
|
25
31
|
"--root <dir>",
|
|
26
32
|
"folder project source files are read from (default: the nearest folder above the cwd that holds the file, else the nearest with a package.json or .git)",
|
|
@@ -58,6 +64,18 @@ const OPTION_HELP = {
|
|
|
58
64
|
resume: ["--resume", "reuse the complete pages already in --out that were scanned with the same options"],
|
|
59
65
|
maxPages: ["--max-pages <N>", "stop after N pages (scanned or resumed); run again with --resume to continue"],
|
|
60
66
|
maxImages: ["--max-images <N>", "stop after the page that brings the images counted to N (checked between pages)"],
|
|
67
|
+
mediaBudget: [
|
|
68
|
+
"--media-budget <N>",
|
|
69
|
+
"sample-encode at most N animated images and videos per run, heaviest first, 0 to 1000 (default 20); the rest keep their cheap hints",
|
|
70
|
+
],
|
|
71
|
+
svgBudget: [
|
|
72
|
+
"--svg-budget <N>",
|
|
73
|
+
"trace at most N flat images (logos, icons, flat illustrations) to SVG and check them, heaviest first, 0 to 1000 (default 50; 0 traces none)",
|
|
74
|
+
],
|
|
75
|
+
mediaTime: ["--media-time <s>", "time limit of the media encodes, in seconds, 1 to 3600 (default 600)"],
|
|
76
|
+
videoBar: ["--video-bar <n>", "minimum VMAF of a video or animated encode, 0 to 100 (default 93)"],
|
|
77
|
+
videoStillBar: ["--video-still-bar <n>", "minimum SSIMULACRA2 of the still-frame guard, 0 to 100 (default 65)"],
|
|
78
|
+
maxVideoWidths: ["--max-video-widths <n>", "the most widths each video is served at, 1 to 3 (default 2)"],
|
|
61
79
|
maxQualities: [
|
|
62
80
|
"--max-qualities <N>",
|
|
63
81
|
"the most values next.config's images.qualities may list, 1 to 4 (default 2): next/image qualities are clustered, each mapped up",
|
|
@@ -95,7 +113,10 @@ const OPTION_HELP = {
|
|
|
95
113
|
"--gpu",
|
|
96
114
|
"score with the GPU scorer (imgtune-ssimu2-gpu, see the docs); opt-in. Falls back to the CPU with a warning",
|
|
97
115
|
],
|
|
98
|
-
gpuDevice: [
|
|
116
|
+
gpuDevice: [
|
|
117
|
+
"--gpu-device <n[,n]>",
|
|
118
|
+
"the GPU to score on, by CUDA index (implies --gpu; default 0; see the GPU notes). A comma list (0,1) gives each GPU its own encode workers with --gpu-encode; the first scores",
|
|
119
|
+
],
|
|
99
120
|
locateOnly: [
|
|
100
121
|
"--locate-only",
|
|
101
122
|
"read the source of the flagged images from a dev page (React owner data) or a built page with source maps (prod-sourcemap); not a measurement (needs --from)",
|
|
@@ -134,6 +155,9 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
|
|
|
134
155
|
"out",
|
|
135
156
|
"root",
|
|
136
157
|
"noCache",
|
|
158
|
+
"noResources",
|
|
159
|
+
"resourceTime",
|
|
160
|
+
"gpuEncode",
|
|
137
161
|
"aggressive",
|
|
138
162
|
"viewports",
|
|
139
163
|
"survey",
|
|
@@ -146,6 +170,12 @@ const SCAN_HELP = commandHelp("imgtune scan <url> [<url> ...] [options]", [
|
|
|
146
170
|
"resume",
|
|
147
171
|
"maxPages",
|
|
148
172
|
"maxImages",
|
|
173
|
+
"mediaBudget",
|
|
174
|
+
"svgBudget",
|
|
175
|
+
"mediaTime",
|
|
176
|
+
"videoBar",
|
|
177
|
+
"videoStillBar",
|
|
178
|
+
"maxVideoWidths",
|
|
149
179
|
"maxQualities",
|
|
150
180
|
"pageConcurrency",
|
|
151
181
|
"idleTimeout",
|
|
@@ -187,11 +217,15 @@ Options:
|
|
|
187
217
|
--out <dir> the check directory (default ${DEFAULT_CHECK_DIR}/<timestamp>)
|
|
188
218
|
--qualities <list> the framework's allowed qualities, e.g. 75,85,100: targets are snapped up to them
|
|
189
219
|
--gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
|
|
220
|
+
--gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
|
|
190
221
|
--page-concurrency <N> pages at once in the deep phase (default 1)
|
|
191
222
|
--idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
|
|
223
|
+
--svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
|
|
192
224
|
--block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
|
|
193
225
|
--allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
|
|
194
226
|
--no-cache do not read or write the result cache
|
|
227
|
+
--no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
|
|
228
|
+
--resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
|
|
195
229
|
--ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
|
|
196
230
|
--storage-state <file> a logged-in session (holds cookies: never commit it)
|
|
197
231
|
-h, --help show this help
|
|
@@ -222,11 +256,15 @@ Options:
|
|
|
222
256
|
--qualities <list> the allowed qualities (default: images.qualities of next.config, else Next's [75])
|
|
223
257
|
--out <dir> the audit directory (default ${DEFAULT_AUDIT_DIR}/<timestamp>)
|
|
224
258
|
--gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
|
|
259
|
+
--gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
|
|
225
260
|
--page-concurrency <N> pages at once in the deep phase (default 1)
|
|
226
261
|
--idle-timeout <ms> cap on the network-idle wait in the deep phase (default 10000)
|
|
262
|
+
--svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
|
|
227
263
|
--block-third-party abort the requests to other sites (off by default; can drop images a page loads from its API)
|
|
228
264
|
--allow-host <h1,h2> with --block-third-party: keep the requests to these hosts
|
|
229
265
|
--no-cache do not read or write the result cache
|
|
266
|
+
--no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
|
|
267
|
+
--resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
|
|
230
268
|
--ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
|
|
231
269
|
--storage-state <file> a logged-in session (holds cookies: never commit it)
|
|
232
270
|
-h, --help show this help
|
|
@@ -252,9 +290,13 @@ Options:
|
|
|
252
290
|
--dev <origin> locate the deep pages at the same paths on this dev server (a running dev server)
|
|
253
291
|
--qualities <list> the framework's allowed qualities, e.g. 75,85,100 (passed to hints)
|
|
254
292
|
--gpu, --gpu-device <n> score the deep pages with the GPU scorer (see the GPU notes; not the survey)
|
|
293
|
+
--gpu-encode EXPERIMENTAL: encode the deep pages' videos and animated images on the GPU (see scan --help)
|
|
255
294
|
--page-concurrency <N> pages at once in the survey (default 4) and in the deep phase (default 1)
|
|
256
295
|
--idle-timeout <ms> cap on the network-idle wait (default 3000 in the survey, 10000 in the deep phase)
|
|
296
|
+
--svg-budget <N> trace at most N flat images of the deep pages to SVG (default 50; 0 traces none)
|
|
257
297
|
--no-cache do not read or write the result cache
|
|
298
|
+
--no-resources skip the resource phase (compression, favicons, fonts, SVG, Lottie, 3D and audio findings; no capture of the other responses)
|
|
299
|
+
--resource-time <s> time limit of the heavy resource work (3D, fonts, SVG, audio, Lottie re-encodes), in seconds, 0 to 3600 (default 60)
|
|
258
300
|
--root <dir> the source folder for --dev (a monorepo sub-folder)
|
|
259
301
|
--storage-state <file> a logged-in session (holds cookies: never commit it)
|
|
260
302
|
--ignore-robots scan pages robots.txt disallows (owned or authorised sites only)
|
|
@@ -298,17 +340,29 @@ against the file itself: 90 (visually lossless) unless --min-score sets another.
|
|
|
298
340
|
--write names a folder for optimised copies (existing names are never overwritten).
|
|
299
341
|
Evidence is "file-mode". Globs: ** walks folders (node_modules and dot folders are skipped).
|
|
300
342
|
|
|
343
|
+
Media: an animated GIF, animated WebP or APNG gets the candidates of animated AVIF, animated WebP and AV1 MP4 at --width
|
|
344
|
+
(the file's own width by default), each the lowest-bytes quality that meets the video bars. A video (MP4, MOV, WebM) is
|
|
345
|
+
described; with --reference <original> a candidate is checked against the original: VMAF and the still-frame guard, one
|
|
346
|
+
verdict line "VMAF <v> (bar <b>) still p10 <s> (bar <sb>): pass|fail", exit 0 on pass and 1 on fail. Media is local files
|
|
347
|
+
only. The encodes need ffmpeg on PATH; without it the sizes are still measured.
|
|
348
|
+
|
|
301
349
|
Options:
|
|
302
|
-
--width <css px> the CSS width the image is shown at (the width is then the user's, not the file's)
|
|
350
|
+
--width <css px> the CSS width the image is shown at (the width is then the user's, not the file's); for media, the width
|
|
351
|
+
compared or searched at (the candidate's own width with --reference)
|
|
303
352
|
--dpr <list> device pixel ratios for --width (default 1,2)
|
|
304
353
|
--formats <list> avif, webp, jpeg (default all three)
|
|
305
354
|
--min-score <n> the SSIMULACRA2 bar, above 0 and at most 100 (default 90)
|
|
306
355
|
--write <dir> write optimised copies (best format, at the largest needed width) into this folder
|
|
307
|
-
--
|
|
356
|
+
--reference <file> compare one media candidate with this original (local file): the verdict line and the exit code
|
|
357
|
+
--video-bar <n> the minimum VMAF of a media encode or check, 0 to 100 (default 93)
|
|
358
|
+
--video-still-bar <n> the still-frame guard's bar, 0 to 100 (default 65), lowered for a source whose own best encode scores low
|
|
359
|
+
--format <text|json> text (default) or JSON (--json is the same)
|
|
308
360
|
--gpu score with the GPU scorer (opt-in; falls back to the CPU with a warning)
|
|
309
|
-
--gpu-device <n> the GPU to score on, by CUDA index (implies --gpu; default 0).
|
|
361
|
+
--gpu-device <n> the GPU to score on, by CUDA index (implies --gpu; default 0). A comma list is accepted: the first GPU
|
|
362
|
+
scores. CUDA numbers the GPUs fastest first
|
|
310
363
|
unless CUDA_DEVICE_ORDER=PCI_BUS_ID; with CUDA_VISIBLE_DEVICES set, n indexes the devices it lists.
|
|
311
364
|
The scorer prints the name of the GPU it opened: check it.
|
|
365
|
+
--gpu-encode EXPERIMENTAL: encode the videos and animated images on the GPU (NVENC, CUDA VMAF), off by default
|
|
312
366
|
--no-cache do not read or write the result cache in .imgtune/cache
|
|
313
367
|
-h, --help show this help
|
|
314
368
|
`;
|
|
@@ -338,6 +392,8 @@ Options:
|
|
|
338
392
|
`;
|
|
339
393
|
const USAGE = `Usage: imgtune <command> [options]
|
|
340
394
|
|
|
395
|
+
imgtune measures page weight on live pages (images, video, hidden downloads, fonts, compression, 3D, audio, Lottie, SVG) and prints the fix for each.
|
|
396
|
+
|
|
341
397
|
Commands:
|
|
342
398
|
scan measure live pages by URL, with no source, config or dev server (writes JSON to --out)
|
|
343
399
|
hints print a batch of the per-image fix hints of a scan file
|
|
@@ -355,6 +411,28 @@ Options:
|
|
|
355
411
|
--version print the version
|
|
356
412
|
-h, --help show this help
|
|
357
413
|
`;
|
|
414
|
+
/** Shell exit codes of a run stopped by a signal: 128 + the signal number. */
|
|
415
|
+
const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143 };
|
|
416
|
+
/** The most a first signal waits for the run to clean up (media scratch, ffmpeg children) before the process exits. */
|
|
417
|
+
const CLEANUP_MS = 2000;
|
|
418
|
+
/** Aborted on the first SIGINT or SIGTERM: the media phase and the image command stop, ffmpeg children are killed and their
|
|
419
|
+
* scratch folders removed (the abort is seen in their `finally` blocks). The process then exits within CLEANUP_MS with the
|
|
420
|
+
* signal's exit code, so a Ctrl-C or a supervisor's SIGTERM always ends the run. A second signal exits at once.
|
|
421
|
+
*/
|
|
422
|
+
const interrupt = new AbortController();
|
|
423
|
+
let interrupts = 0;
|
|
424
|
+
/** The first signal received, which sets the exit code of an interrupted run. */
|
|
425
|
+
let received;
|
|
426
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
427
|
+
process.on(signal, () => {
|
|
428
|
+
interrupts++;
|
|
429
|
+
received ??= signal;
|
|
430
|
+
if (interrupts > 1)
|
|
431
|
+
process.exit(SIGNAL_EXIT[signal]);
|
|
432
|
+
interrupt.abort();
|
|
433
|
+
setTimeout(() => process.exit(SIGNAL_EXIT[signal]), CLEANUP_MS);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
358
436
|
async function main() {
|
|
359
437
|
const argv = process.argv.slice(2);
|
|
360
438
|
if (argv.includes("--version")) {
|
|
@@ -415,9 +493,9 @@ async function main() {
|
|
|
415
493
|
return;
|
|
416
494
|
}
|
|
417
495
|
if (argv[0] === "image") {
|
|
418
|
-
const outcome = await imageCommand(argv.slice(1), process.cwd());
|
|
496
|
+
const outcome = await imageCommand(argv.slice(1), process.cwd(), undefined, undefined, interrupt.signal);
|
|
419
497
|
process.stdout.write(outcome.output);
|
|
420
|
-
process.exitCode = outcome.exitCode;
|
|
498
|
+
process.exitCode = received !== undefined ? SIGNAL_EXIT[received] : outcome.exitCode;
|
|
421
499
|
return;
|
|
422
500
|
}
|
|
423
501
|
if (argv[0] === "hints") {
|
|
@@ -455,9 +533,9 @@ async function main() {
|
|
|
455
533
|
process.exitCode = 2;
|
|
456
534
|
return;
|
|
457
535
|
}
|
|
458
|
-
const outcome = await scanCommand(argv.slice(1), process.cwd(), progress);
|
|
536
|
+
const outcome = await scanCommand(argv.slice(1), process.cwd(), progress, interrupt.signal);
|
|
459
537
|
process.stdout.write(outcome.output);
|
|
460
|
-
process.exitCode = outcome.exitCode;
|
|
538
|
+
process.exitCode = received !== undefined ? SIGNAL_EXIT[received] : outcome.exitCode;
|
|
461
539
|
}
|
|
462
540
|
main().catch((err) => {
|
|
463
541
|
if (err instanceof ChromiumMissingError) {
|
package/dist/egress-proxy.js
CHANGED
|
@@ -38,8 +38,9 @@ function connectionFor(hostname, policy, onRefused) {
|
|
|
38
38
|
const checked = guardedLookup(policy.resolver, policy.allowPrivate, policy.connectAddress);
|
|
39
39
|
const lookup = ((name, options, callback) => {
|
|
40
40
|
checked(name, options, (err, ...rest) => {
|
|
41
|
+
// The name a lookup could not resolve: the page may still load it in a browser (see the scan's unresolved hosts).
|
|
41
42
|
if (err)
|
|
42
|
-
onRefused();
|
|
43
|
+
onRefused(name);
|
|
43
44
|
callback(err, ...rest);
|
|
44
45
|
});
|
|
45
46
|
});
|
package/dist/fetch.js
CHANGED
|
@@ -75,7 +75,7 @@ export function makeFetcher(opts) {
|
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
77
|
/** Each hop is checked at connect time (guardedLookup); redirects are followed here, not by the client. */
|
|
78
|
-
const fetchChecked = async (start, maxBytes, allowPrivate, sameSiteAs, sameOriginAs) => {
|
|
78
|
+
const fetchChecked = async (start, maxBytes, allowPrivate, sameSiteAs, sameOriginAs, allowHop) => {
|
|
79
79
|
const controller = new AbortController();
|
|
80
80
|
const timer = setTimeout(() => controller.abort(), EXTRA_FETCH_TIMEOUT_MS);
|
|
81
81
|
try {
|
|
@@ -99,7 +99,11 @@ export function makeFetcher(opts) {
|
|
|
99
99
|
res.resume();
|
|
100
100
|
if (hop >= MAX_REDIRECTS)
|
|
101
101
|
return undefined;
|
|
102
|
-
|
|
102
|
+
const next = new URL(location, url).toString();
|
|
103
|
+
// A hop the policy refuses is not requested: the fetch ends here, as a failed one does.
|
|
104
|
+
if (allowHop !== undefined && !allowHop(next))
|
|
105
|
+
return undefined;
|
|
106
|
+
current = next;
|
|
103
107
|
continue;
|
|
104
108
|
}
|
|
105
109
|
if (status < 200 || status >= 300) {
|
|
@@ -137,14 +141,15 @@ export function makeFetcher(opts) {
|
|
|
137
141
|
const maxBytes = fetchOpts.maxBytes ?? MAX_IMAGE_BYTES;
|
|
138
142
|
const allowPrivate = fetchOpts.allowPrivate ?? false;
|
|
139
143
|
// The request headers are part of the key: a response can depend on them (the user agent and Accept are fixed per run).
|
|
140
|
-
const
|
|
144
|
+
const hopKey = fetchOpts.allowHop === undefined ? "" : `hop:${fetchOpts.allowHopKey ?? ""}`;
|
|
145
|
+
const key = JSON.stringify([url, maxBytes, allowPrivate, fetchOpts.sameSiteAs ?? "", fetchOpts.sameOriginAs ?? "", opts.userAgent, IMAGE_ACCEPT, hopKey]);
|
|
141
146
|
const known = memo.get(key);
|
|
142
147
|
if (known) {
|
|
143
148
|
count("fetch memo hits");
|
|
144
149
|
return known;
|
|
145
150
|
}
|
|
146
151
|
count("fetch requests");
|
|
147
|
-
const fetched = measure("fetch: network", () => withSlot(() => fetchChecked(url, maxBytes, allowPrivate, fetchOpts.sameSiteAs, fetchOpts.sameOriginAs))).catch(() => undefined);
|
|
152
|
+
const fetched = measure("fetch: network", () => withSlot(() => fetchChecked(url, maxBytes, allowPrivate, fetchOpts.sameSiteAs, fetchOpts.sameOriginAs, fetchOpts.allowHop))).catch(() => undefined);
|
|
148
153
|
const pending = fetched.then((answer) => {
|
|
149
154
|
// Settled: a failure or a no-store body is not kept for the next page (its callers above already have it).
|
|
150
155
|
if (answer === undefined || answer.noStore) {
|