webperf-snippets 1.0.0 → 1.1.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/README.md +55 -16
- package/package.json +7 -2
- package/snippets/CoreWebVitals/INP.js +134 -62
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +27 -4
- package/snippets/CoreWebVitals/LCP-Subparts.js +29 -2
- package/snippets/CoreWebVitals/WORKFLOWS.md +2 -2
- package/snippets/Interaction/Input-Latency-Breakdown.js +87 -36
- package/snippets/Interaction/Interactions.js +90 -38
- package/snippets/Interaction/Long-Animation-Frames.js +20 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +2 -1
- package/snippets/Loading/FCP.js +34 -7
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +51 -11
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +2 -1
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +2 -1
- package/snippets/Loading/Inline-Script-Info-and-Size.js +26 -6
- package/snippets/Loading/Script-Loading.js +15 -3
- package/snippets/Loading/TTFB-Resources.js +2 -1
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +27 -5
- package/snippets/SCHEMA.md +76 -30
- package/snippets/_shared/helpers.js +72 -0
- package/src/bin.js +25 -21
- package/src/browser.js +22 -0
- package/src/crux.js +5 -3
- package/src/mcp/documents.js +111 -0
- package/src/mcp/prompts.js +101 -0
- package/src/mcp/server.js +382 -0
- package/src/mcp/sessions.js +91 -0
- package/src/mcp/stdio.js +10 -0
- package/src/reporters/human.js +5 -1
- package/src/runner.js +29 -7
- package/src/snippet-names.js +10 -1
- package/src/workflows/index.js +12 -0
package/src/bin.js
CHANGED
|
@@ -4,29 +4,25 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { loadSnippet } from "./load-snippet.js";
|
|
5
5
|
import { resolveSnippetName } from "./snippet-names.js";
|
|
6
6
|
import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
|
|
7
|
-
import {
|
|
8
|
-
import { auditWorkflow } from "./workflows/audit.js";
|
|
9
|
-
import { loadingWorkflow } from "./workflows/loading.js";
|
|
10
|
-
import { interactionWorkflow } from "./workflows/interaction.js";
|
|
7
|
+
import { WORKFLOWS } from "./workflows/index.js";
|
|
11
8
|
import { RULES } from "./decision-tree.js";
|
|
12
9
|
import { reportHuman } from "./reporters/human.js";
|
|
13
10
|
import { reportJson } from "./reporters/json.js";
|
|
14
11
|
import { reportMarkdown } from "./reporters/markdown.js";
|
|
15
12
|
import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
|
|
16
13
|
import { fetchCrux, formFactorFor } from "./crux.js";
|
|
17
|
-
|
|
18
|
-
const WORKFLOWS = {
|
|
19
|
-
"core-web-vitals": cwvWorkflow,
|
|
20
|
-
audit: auditWorkflow,
|
|
21
|
-
loading: loadingWorkflow,
|
|
22
|
-
interaction: interactionWorkflow,
|
|
23
|
-
};
|
|
14
|
+
import { MissingBrowserError } from "./browser.js";
|
|
24
15
|
|
|
25
16
|
|
|
26
17
|
const USAGE = `webperf-snippets <url> [options]
|
|
18
|
+
webperf-snippets mcp
|
|
27
19
|
|
|
28
20
|
Run curated WebPerf Snippets headlessly via Playwright.
|
|
29
21
|
|
|
22
|
+
Commands:
|
|
23
|
+
mcp Start the MCP server over stdio, for AI agents
|
|
24
|
+
(claude mcp add webperf -- npx -y webperf-snippets mcp)
|
|
25
|
+
|
|
30
26
|
Options:
|
|
31
27
|
--workflow <name> Workflow to run (default: core-web-vitals)
|
|
32
28
|
Workflows: core-web-vitals, audit, loading, interaction
|
|
@@ -59,15 +55,15 @@ Options:
|
|
|
59
55
|
-h, --help Show this help
|
|
60
56
|
|
|
61
57
|
Examples:
|
|
62
|
-
npx webperf-snippets https://
|
|
63
|
-
npx webperf-snippets https://
|
|
64
|
-
npx webperf-snippets https://
|
|
65
|
-
npx webperf-snippets https://
|
|
66
|
-
npx webperf-snippets https://
|
|
67
|
-
npx webperf-snippets https://
|
|
68
|
-
npx webperf-snippets https://
|
|
69
|
-
npx webperf-snippets https://
|
|
70
|
-
npx webperf-snippets https://
|
|
58
|
+
npx webperf-snippets https://example.com
|
|
59
|
+
npx webperf-snippets https://example.com --workflow audit
|
|
60
|
+
npx webperf-snippets https://example.com --json
|
|
61
|
+
npx webperf-snippets https://example.com --snippet LCP-Subparts
|
|
62
|
+
npx webperf-snippets https://example.com --snippet render-blocking
|
|
63
|
+
npx webperf-snippets https://example.com --snippet fonts
|
|
64
|
+
npx webperf-snippets https://example.com --snippet Compression-Audit
|
|
65
|
+
npx webperf-snippets https://example.com --budget-lcp 2500
|
|
66
|
+
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
71
67
|
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
72
68
|
`;
|
|
73
69
|
|
|
@@ -104,6 +100,13 @@ function checkBudgets(results, values) {
|
|
|
104
100
|
}
|
|
105
101
|
|
|
106
102
|
async function main() {
|
|
103
|
+
// The MCP server only loads its SDK when it is asked for
|
|
104
|
+
if (process.argv[2] === "mcp") {
|
|
105
|
+
const { startStdioServer } = await import("./mcp/stdio.js");
|
|
106
|
+
await startStdioServer();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
107
110
|
let parsed;
|
|
108
111
|
try {
|
|
109
112
|
parsed = parseArgs({
|
|
@@ -228,6 +231,7 @@ async function main() {
|
|
|
228
231
|
}
|
|
229
232
|
|
|
230
233
|
main().catch((err) => {
|
|
231
|
-
|
|
234
|
+
// A missing Chromium is a step of the setup, not a bug: the message says what to run, without a stack
|
|
235
|
+
process.stderr.write(err instanceof MissingBrowserError ? `${err.message}\n` : `Error: ${err.stack ?? err.message}\n`);
|
|
232
236
|
process.exit(1);
|
|
233
237
|
});
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
|
|
3
|
+
// Chromium for Playwright is a download of its own: npx installs this package and its peer playwright,
|
|
4
|
+
// not the browser. Without it, Playwright fails with the path of the missing executable, and its advice
|
|
5
|
+
// (npx playwright install) downloads every browser; this message names the only one the CLI uses.
|
|
6
|
+
export const MISSING_BROWSER = "Chromium for Playwright is not installed. Install it once with: npx playwright install chromium";
|
|
7
|
+
|
|
8
|
+
export class MissingBrowserError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super(MISSING_BROWSER);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const isMissingBrowser = (err) => err instanceof MissingBrowserError || /Executable doesn't exist/.test(err?.message ?? "");
|
|
15
|
+
|
|
16
|
+
export async function launchChromium(options) {
|
|
17
|
+
try {
|
|
18
|
+
return await chromium.launch(options);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw isMissingBrowser(err) ? new MissingBrowserError() : err;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/crux.js
CHANGED
|
@@ -40,7 +40,9 @@ async function query({ endpoint, apiKey, body, timeoutMs }) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Never throws, and no message it returns contains the key (it travels in the
|
|
43
|
-
// query string, so the request URL is never logged or put in an error).
|
|
43
|
+
// query string, so the request URL is never logged or put in an error). A page
|
|
44
|
+
// with no field data is an answer ({ unavailable }); an API error or no answer is
|
|
45
|
+
// a failure ({ unavailable, failed: true }).
|
|
44
46
|
export async function fetchCrux({
|
|
45
47
|
url,
|
|
46
48
|
apiKey,
|
|
@@ -56,13 +58,13 @@ export async function fetchCrux({
|
|
|
56
58
|
answer = await query({ endpoint, apiKey, body: { origin: new URL(url).origin, formFactor }, timeoutMs });
|
|
57
59
|
}
|
|
58
60
|
if (answer.notFound) return { unavailable: "not in the CrUX dataset" };
|
|
59
|
-
if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}
|
|
61
|
+
if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}`, failed: true };
|
|
60
62
|
const metrics = parseMetrics(answer.record);
|
|
61
63
|
if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX record" };
|
|
62
64
|
return { scope, formFactor, metrics };
|
|
63
65
|
} catch (err) {
|
|
64
66
|
const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
|
|
65
|
-
return { unavailable: reason };
|
|
67
|
+
return { unavailable: reason, failed: true };
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ResourceTemplate, ResourceNotFoundError } from "@modelcontextprotocol/server";
|
|
4
|
+
import { SNIPPET_DIRS } from "../load-snippet.js";
|
|
5
|
+
import { CATEGORIES } from "../snippet-names.js";
|
|
6
|
+
|
|
7
|
+
const MARKDOWN = "text/markdown";
|
|
8
|
+
|
|
9
|
+
// A document of snippets/, from the copy bundled with the package or from the repository
|
|
10
|
+
function readDocument(relativePath) {
|
|
11
|
+
for (const dir of SNIPPET_DIRS) {
|
|
12
|
+
try {
|
|
13
|
+
return readFileSync(join(dir, relativePath), "utf8");
|
|
14
|
+
} catch {
|
|
15
|
+
// try the next directory
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Splits SCHEMA.md into the general contract and the section of each snippet ("#### LCP" up to the next
|
|
22
|
+
// heading of that level or above). The general contract is everything outside "## Script-Specific Schemas".
|
|
23
|
+
export function splitSchema(markdown) {
|
|
24
|
+
const lines = markdown.split("\n");
|
|
25
|
+
const start = lines.findIndex((l) => l.startsWith("## Script-Specific Schemas"));
|
|
26
|
+
const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
|
|
27
|
+
const general = [...lines.slice(0, start), ...lines.slice(end)].join("\n");
|
|
28
|
+
|
|
29
|
+
const sections = new Map();
|
|
30
|
+
let name = null;
|
|
31
|
+
let current = [];
|
|
32
|
+
const flush = () => name && sections.set(name, current.join("\n").trim());
|
|
33
|
+
for (const line of lines.slice(start, end)) {
|
|
34
|
+
if (line.startsWith("#### ")) {
|
|
35
|
+
flush();
|
|
36
|
+
name = line.slice(5).split(" ")[0];
|
|
37
|
+
current = [line];
|
|
38
|
+
} else if (/^#{1,3} /.test(line)) {
|
|
39
|
+
flush();
|
|
40
|
+
name = null;
|
|
41
|
+
} else if (name) {
|
|
42
|
+
current.push(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
flush();
|
|
46
|
+
return { general, sections };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let schema = null;
|
|
50
|
+
export function schemaSection(snippet) {
|
|
51
|
+
schema ??= splitSchema(readDocument("SCHEMA.md") ?? "");
|
|
52
|
+
return snippet === undefined ? schema.general : schema.sections.get(snippet);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function schemaSnippets() {
|
|
56
|
+
schemaSection();
|
|
57
|
+
return [...schema.sections.keys()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const markdown = (uri, text) => ({ contents: [{ uri: uri.href, mimeType: MARKDOWN, text }] });
|
|
61
|
+
|
|
62
|
+
export function registerResources(server) {
|
|
63
|
+
server.registerResource(
|
|
64
|
+
"schema",
|
|
65
|
+
"webperf://schema",
|
|
66
|
+
{
|
|
67
|
+
title: "Snippet result contract",
|
|
68
|
+
description: "What every snippet returns: the base shape, the statuses, the execution patterns and how to read a result. The fields of each snippet are in webperf://schema/{snippet}.",
|
|
69
|
+
mimeType: MARKDOWN,
|
|
70
|
+
},
|
|
71
|
+
async (uri) => markdown(uri, schemaSection())
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
server.registerResource(
|
|
75
|
+
"snippet-schema",
|
|
76
|
+
new ResourceTemplate("webperf://schema/{snippet}", {
|
|
77
|
+
list: async () => ({
|
|
78
|
+
resources: schemaSnippets().map((snippet) => ({ uri: `webperf://schema/${snippet}`, name: `${snippet} result`, mimeType: MARKDOWN })),
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
{
|
|
82
|
+
title: "Result of one snippet",
|
|
83
|
+
description: "The fields one snippet returns, with an example, from snippets/SCHEMA.md.",
|
|
84
|
+
mimeType: MARKDOWN,
|
|
85
|
+
},
|
|
86
|
+
async (uri, { snippet }) => {
|
|
87
|
+
const section = schemaSection(snippet);
|
|
88
|
+
if (!section) throw new ResourceNotFoundError(uri.href);
|
|
89
|
+
return markdown(uri, section);
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
server.registerResource(
|
|
94
|
+
"workflows",
|
|
95
|
+
new ResourceTemplate("webperf://workflows/{category}", {
|
|
96
|
+
list: async () => ({
|
|
97
|
+
resources: CATEGORIES.map((category) => ({ uri: `webperf://workflows/${category}`, name: `${category} workflows`, mimeType: MARKDOWN })),
|
|
98
|
+
}),
|
|
99
|
+
}),
|
|
100
|
+
{
|
|
101
|
+
title: "Workflows and decision tree",
|
|
102
|
+
description: "The snippets to run for common questions of a category, and what to run next after each one depending on its result.",
|
|
103
|
+
mimeType: MARKDOWN,
|
|
104
|
+
},
|
|
105
|
+
async (uri, { category }) => {
|
|
106
|
+
const text = CATEGORIES.includes(category) ? readDocument(`${category}/WORKFLOWS.md`) : null;
|
|
107
|
+
if (!text) throw new ResourceNotFoundError(uri.href);
|
|
108
|
+
return markdown(uri, text);
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { schemaSection } from "./documents.js";
|
|
3
|
+
|
|
4
|
+
// Prompts are templates the user picks (in Claude Code, as /mcp__webperf__audit_lcp). Each one says
|
|
5
|
+
// which tools to chain and in what order, and embeds the schema sections the answer needs, so the model
|
|
6
|
+
// reads the results with the same reference the skills use.
|
|
7
|
+
const text = (value) => ({ role: "user", content: { type: "text", text: value } });
|
|
8
|
+
const schemaOf = (snippet) => ({
|
|
9
|
+
role: "user",
|
|
10
|
+
content: { type: "resource", resource: { uri: `webperf://schema/${snippet}`, mimeType: "text/markdown", text: schemaSection(snippet) } },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const Url = z.string().describe("The page to audit");
|
|
14
|
+
const Viewport = z.enum(["mobile", "desktop"]).optional().describe("mobile by default");
|
|
15
|
+
const QUOTE = "Quote the values the tools return; do not estimate or round them up.";
|
|
16
|
+
|
|
17
|
+
export function registerPrompts(server) {
|
|
18
|
+
server.registerPrompt(
|
|
19
|
+
"audit_lcp",
|
|
20
|
+
{
|
|
21
|
+
title: "Audit LCP",
|
|
22
|
+
description: "Measures the Largest Contentful Paint of a page in the lab and in the field, finds its slowest sub-part and proposes fixes.",
|
|
23
|
+
argsSchema: z.object({ url: Url, viewport: Viewport }),
|
|
24
|
+
},
|
|
25
|
+
({ url, viewport = "mobile" }) => ({
|
|
26
|
+
messages: [
|
|
27
|
+
text(
|
|
28
|
+
[
|
|
29
|
+
`Audit the Largest Contentful Paint of ${url} on ${viewport}.`,
|
|
30
|
+
"",
|
|
31
|
+
`1. Call run_workflow with url "${url}", workflow "core-web-vitals" and viewport "${viewport}". It returns LCP and CLS, and adds LCP-Subparts with the reason when LCP is over 2.5 s.`,
|
|
32
|
+
`2. Call get_crux for the same URL with formFactor "${viewport === "desktop" ? "desktop" : "phone"}", to compare the lab with what real users get. If it answers that there is no key, say so and go on with the lab data.`,
|
|
33
|
+
'3. If LCP-Subparts did not run, call run_snippet with snippet "LCP-Subparts".',
|
|
34
|
+
"4. Report the LCP value and rating in the lab and in the field, the LCP element, the slowest sub-part and what it means, and the fixes for that sub-part, the most effective first.",
|
|
35
|
+
"",
|
|
36
|
+
`${QUOTE} The result of each snippet is described below.`,
|
|
37
|
+
].join("\n")
|
|
38
|
+
),
|
|
39
|
+
schemaOf("LCP"),
|
|
40
|
+
schemaOf("LCP-Subparts"),
|
|
41
|
+
],
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
server.registerPrompt(
|
|
46
|
+
"debug_inp",
|
|
47
|
+
{
|
|
48
|
+
title: "Debug INP",
|
|
49
|
+
description: "Opens a page, clicks on it like a user and finds the slowest interaction, its target and the phase that makes it slow.",
|
|
50
|
+
argsSchema: z.object({
|
|
51
|
+
url: Url,
|
|
52
|
+
selector: z.string().optional().describe("A CSS selector of the element to click, such as #buy"),
|
|
53
|
+
viewport: Viewport,
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
({ url, selector, viewport = "mobile" }) => ({
|
|
57
|
+
messages: [
|
|
58
|
+
text(
|
|
59
|
+
[
|
|
60
|
+
`Find what makes the interactions slow on ${url} (${viewport}).`,
|
|
61
|
+
"",
|
|
62
|
+
`1. Call open_page with url "${url}", viewport "${viewport}" and track ["INP", "Interactions", "Input-Latency-Breakdown"].`,
|
|
63
|
+
selector
|
|
64
|
+
? `2. Call interact with a click on "${selector}". Add a few more clicks or key presses if the page has other controls a user would use.`
|
|
65
|
+
: "2. Call interact with clicks on the controls a user would use first: main buttons, links that open menus, form fields. If you cannot tell which ones, ask me.",
|
|
66
|
+
"3. Call read_results. INP gives the worst interaction and its phases (input delay, processing, presentation); Input-Latency-Breakdown says which phase dominates for each type of event.",
|
|
67
|
+
"4. Call close_page.",
|
|
68
|
+
"5. Report the worst interaction, its target, the dominant phase and the fixes for that phase.",
|
|
69
|
+
"",
|
|
70
|
+
`${QUOTE} The result of INP is described below.`,
|
|
71
|
+
].join("\n")
|
|
72
|
+
),
|
|
73
|
+
schemaOf("INP"),
|
|
74
|
+
],
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.registerPrompt(
|
|
79
|
+
"audit_page",
|
|
80
|
+
{
|
|
81
|
+
title: "Audit a page",
|
|
82
|
+
description: "Runs the audit and loading workflows on a page and summarizes the issues by severity, with fixes.",
|
|
83
|
+
argsSchema: z.object({ url: Url, viewport: Viewport }),
|
|
84
|
+
},
|
|
85
|
+
({ url, viewport = "mobile" }) => ({
|
|
86
|
+
messages: [
|
|
87
|
+
text(
|
|
88
|
+
[
|
|
89
|
+
`Audit the loading performance of ${url} on ${viewport}.`,
|
|
90
|
+
"",
|
|
91
|
+
`1. Call run_workflow with url "${url}", workflow "audit" and viewport "${viewport}".`,
|
|
92
|
+
'2. Call run_workflow with the same URL and workflow "loading".',
|
|
93
|
+
"3. Summarize the issues the snippets report, errors first, then warnings, each with the snippet that found it and the fix. Leave out the checks that passed.",
|
|
94
|
+
"",
|
|
95
|
+
`${QUOTE} If a result is unclear, read the resource webperf://schema/{snippet} of that snippet.`,
|
|
96
|
+
].join("\n")
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
}
|