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/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 { cwvWorkflow } from "./workflows/cwv.js";
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://web.dev
63
- npx webperf-snippets https://web.dev --workflow audit
64
- npx webperf-snippets https://web.dev --json
65
- npx webperf-snippets https://web.dev --snippet LCP-Subparts
66
- npx webperf-snippets https://web.dev --snippet render-blocking
67
- npx webperf-snippets https://web.dev --snippet fonts
68
- npx webperf-snippets https://web.dev --snippet Compression-Audit
69
- npx webperf-snippets https://web.dev --budget-lcp 2500
70
- npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
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
- process.stderr.write(`Error: ${err.stack ?? err.message}\n`);
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
+ }