webperf-snippets 1.0.0 → 1.2.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.
Files changed (36) hide show
  1. package/README.md +96 -17
  2. package/package.json +7 -2
  3. package/snippets/CoreWebVitals/INP.js +134 -62
  4. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +27 -4
  5. package/snippets/CoreWebVitals/LCP-Subparts.js +29 -2
  6. package/snippets/CoreWebVitals/WORKFLOWS.md +2 -2
  7. package/snippets/Interaction/Input-Latency-Breakdown.js +87 -36
  8. package/snippets/Interaction/Interactions.js +90 -38
  9. package/snippets/Interaction/Long-Animation-Frames.js +20 -1
  10. package/snippets/Loading/Cache-Strategy-Analysis.js +2 -1
  11. package/snippets/Loading/FCP.js +34 -7
  12. package/snippets/Loading/First-And-Third-Party-Script-Info.js +51 -11
  13. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +2 -1
  14. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +2 -1
  15. package/snippets/Loading/Inline-Script-Info-and-Size.js +26 -6
  16. package/snippets/Loading/Script-Loading.js +15 -3
  17. package/snippets/Loading/TTFB-Resources.js +2 -1
  18. package/snippets/Loading/Third-Party-Impact-by-Domain.js +27 -5
  19. package/snippets/SCHEMA.md +76 -30
  20. package/snippets/_shared/helpers.js +72 -0
  21. package/src/bin.js +64 -24
  22. package/src/browser.js +22 -0
  23. package/src/crux.js +5 -3
  24. package/src/headers.js +35 -0
  25. package/src/mcp/documents.js +111 -0
  26. package/src/mcp/prompts.js +101 -0
  27. package/src/mcp/server.js +382 -0
  28. package/src/mcp/sessions.js +91 -0
  29. package/src/mcp/stdio.js +10 -0
  30. package/src/own-domains.js +16 -0
  31. package/src/reporters/human.js +8 -2
  32. package/src/reporters/markdown.js +3 -1
  33. package/src/runner.js +50 -12
  34. package/src/snippet-names.js +10 -1
  35. package/src/throttling.js +43 -0
  36. package/src/workflows/index.js +12 -0
@@ -0,0 +1,382 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import * as z from "zod/v4";
3
+ import { cliVersion } from "../report-to.js";
4
+ import { CATEGORIES, aliasesOf, listSnippets, resolveSnippetName } from "../snippet-names.js";
5
+ import { loadSnippet } from "../load-snippet.js";
6
+ import { isTrackingSnippet } from "../tracking.js";
7
+ import { runSnippets, runMeasurement, evaluateItems, collectTrackingData, VIEWPORT_PRESETS } from "../runner.js";
8
+ import { WORKFLOWS } from "../workflows/index.js";
9
+ import { RULES } from "../decision-tree.js";
10
+ import { fetchCrux } from "../crux.js";
11
+ import { runInteractions, isInputStep, settle } from "../interactions.js";
12
+ import { createSessions, SessionError } from "./sessions.js";
13
+ import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
14
+ import { registerResources } from "./documents.js";
15
+ import { registerPrompts } from "./prompts.js";
16
+
17
+ const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
18
+ const STAGES = 3;
19
+ const NAV_TIMEOUT_MS = 30000;
20
+
21
+ const HttpUrl = z
22
+ .string()
23
+ .refine((url) => /^https?:\/\//i.test(url) && URL.canParse(url), "url must be an http or https URL")
24
+ .describe("The page to measure; local development servers (http://localhost:3000) work too");
25
+
26
+ // Free-form beyond script and status: each snippet has its own fields (snippets/SCHEMA.md). zod writes
27
+ // that as `additionalProperties: {}`; `true` says the same in a way every client reads.
28
+ const SnippetResult = z
29
+ .looseObject({ script: z.string(), status: z.string() })
30
+ .meta({ additionalProperties: true })
31
+ .describe("The object the snippet returns");
32
+
33
+ const SessionId = z.string().describe("The id open_page returned");
34
+
35
+ const Step = z.discriminatedUnion("action", [
36
+ z.object({ action: z.literal("click"), selector: z.string() }),
37
+ z.object({ action: z.literal("hover"), selector: z.string() }),
38
+ z.object({ action: z.literal("type"), selector: z.string(), text: z.string() }),
39
+ z.object({ action: z.literal("scroll"), y: z.number().int().optional().describe("Pixels down, 300 by default") }),
40
+ z.object({ action: z.literal("wait"), ms: z.number().int().min(0).max(10000) }),
41
+ ]);
42
+
43
+ const SnippetInfo = z.object({
44
+ name: z.string().describe("Category/Name, the name other tools take"),
45
+ category: z.enum(CATEGORIES),
46
+ title: z.string(),
47
+ aliases: z.array(z.string()).describe("Short names that also select the snippet"),
48
+ tracking: z.boolean().describe("The snippet records interactions and answers after them"),
49
+ });
50
+
51
+ // Every snippet starts with a comment that names it: "// LCP Quick Check"
52
+ const titleOf = (source) => source.split("\n", 1)[0].replace(/^\/\/\s*/, "").trim();
53
+
54
+ // The result of a tool, as structured content for clients that read it and as the same JSON in text
55
+ // for those that do not
56
+ const toolResult = (data) => ({ structuredContent: data, content: [{ type: "text", text: JSON.stringify(data) }] });
57
+
58
+ // A failure the model reads and can act on: a wrong argument or a page that does not load. It is
59
+ // still a result of the tool, not an error of the protocol.
60
+ const toolError = (text) => ({ isError: true, content: [{ type: "text", text }] });
61
+
62
+ const firstLine = (message) => message.split("\n")[0];
63
+
64
+ // A client that wants progress sends a token with the call; the stages of a run go to it
65
+ function progressFor(ctx) {
66
+ const progressToken = ctx.mcpReq._meta?.progressToken;
67
+ return (progress, message) =>
68
+ progressToken === undefined
69
+ ? undefined
70
+ : ctx.mcpReq.notify({ method: "notifications/progress", params: { progressToken, progress, total: STAGES, message } });
71
+ }
72
+
73
+ const CRUX_FORM_FACTORS = { phone: "PHONE", desktop: "DESKTOP", tablet: "TABLET" };
74
+ const CRUX_SETUP =
75
+ "get_crux needs a CrUX API key in the CRUX_API_KEY environment variable of the MCP server. Set it in the configuration of the client, for example: claude mcp add webperf -e CRUX_API_KEY=<key> -- npx -y webperf-snippets mcp. A key is free in the Google Cloud console (Chrome UX Report API).";
76
+
77
+ function describeSnippet(name) {
78
+ const source = loadSnippet(name);
79
+ return {
80
+ name,
81
+ category: name.split("/")[0],
82
+ title: titleOf(source),
83
+ aliases: aliasesOf(name),
84
+ tracking: isTrackingSnippet(name, source),
85
+ };
86
+ }
87
+
88
+ // The MCP server of the CLI: the same snippets and runner, offered as tools an agent calls by name.
89
+ // serveStdio creates one instance per connection, so the pages it keeps open belong to that client.
90
+ export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env } = {}) {
91
+ const server = new McpServer({ name: "webperf-snippets", version: cliVersion });
92
+ const sessions = createSessions({ launchBrowser, maxPages, idleMs });
93
+ // The client left (end of stdin, or a closed transport): close the pages and Chromium
94
+ server.server.onclose = () => sessions.closeAll();
95
+ registerResources(server);
96
+ registerPrompts(server);
97
+
98
+ // Runs a session tool, and turns the errors the model can act on into tool errors
99
+ const withSession = (handler) => async (args, ctx) => {
100
+ try {
101
+ return await handler(args, ctx);
102
+ } catch (err) {
103
+ if (err instanceof SessionError) return toolError(err.message);
104
+ throw err;
105
+ }
106
+ };
107
+
108
+ server.registerTool(
109
+ "list_snippets",
110
+ {
111
+ title: "List snippets",
112
+ description:
113
+ "Lists the web performance snippets this server can run, with their category, title and short aliases. Call it to find the name of the snippet to run.",
114
+ inputSchema: z.object({
115
+ category: z.enum(CATEGORIES).optional().describe("Only the snippets of this category"),
116
+ }),
117
+ outputSchema: z.object({ snippets: z.array(SnippetInfo) }),
118
+ annotations: { readOnlyHint: true, openWorldHint: false },
119
+ },
120
+ async ({ category }) => {
121
+ const snippets = listSnippets()
122
+ .filter((name) => !category || name.startsWith(`${category}/`))
123
+ .map(describeSnippet);
124
+ return toolResult({ snippets });
125
+ }
126
+ );
127
+
128
+ server.registerTool(
129
+ "run_snippet",
130
+ {
131
+ title: "Run a snippet",
132
+ description:
133
+ "Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run on a page opened with open_page instead.",
134
+ inputSchema: z.object({
135
+ url: HttpUrl,
136
+ snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
137
+ viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
138
+ waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
139
+ }),
140
+ outputSchema: z.object({
141
+ url: z.string(),
142
+ snippet: z.string().describe("Category/Name of the snippet that ran"),
143
+ viewport: z.enum(VIEWPORTS),
144
+ navMs: z.number().describe("From the start of the navigation to the end of the wait"),
145
+ pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
146
+ result: SnippetResult,
147
+ }),
148
+ annotations: { readOnlyHint: true, openWorldHint: true },
149
+ },
150
+ async ({ url, snippet, viewport, waitMs }, ctx) => {
151
+ let path;
152
+ try {
153
+ path = resolveSnippetName(snippet);
154
+ } catch (err) {
155
+ return toolError(err.message);
156
+ }
157
+ const source = loadSnippet(path);
158
+ if (isTrackingSnippet(path, source)) {
159
+ return toolError(`${path} records interactions: open the page with open_page and this snippet in track, use it with interact, and read it with read_results.`);
160
+ }
161
+
162
+ try {
163
+ const run = await runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress: progressFor(ctx) });
164
+ const { id, ...result } = run.results[0];
165
+ return toolResult({ url, snippet: id, viewport, navMs: run.navMs, pageErrors: run.pageErrors, result });
166
+ } catch (err) {
167
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
168
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
169
+ return toolError(`The run failed: ${firstLine(err.message)}`);
170
+ }
171
+ }
172
+ );
173
+
174
+ server.registerTool(
175
+ "run_workflow",
176
+ {
177
+ title: "Run a workflow",
178
+ description:
179
+ "Opens the URL in a headless Chromium and runs a workflow: a sequence of snippets plus the follow-ups its decision tree adds from the results (for example LCP-Subparts when LCP is over 2.5 s, with the reason). core-web-vitals: LCP and CLS. audit: render-blocking resources, resource hints, critical CSS, TTFB, scripts, images and more. loading: TTFB, FCP, render-blocking, scripts and fonts. interaction: long tasks, animation frames, scroll and layout shifts while it scrolls the page.",
180
+ inputSchema: z.object({
181
+ url: HttpUrl,
182
+ workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
183
+ viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
184
+ waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
185
+ }),
186
+ outputSchema: z.object({
187
+ url: z.string(),
188
+ workflow: z.string(),
189
+ viewport: z.enum(VIEWPORTS),
190
+ navMs: z.number(),
191
+ pageErrors: z.array(z.string()),
192
+ results: z
193
+ .array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
194
+ .describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
195
+ }),
196
+ annotations: { readOnlyHint: true, openWorldHint: true },
197
+ },
198
+ async ({ url, workflow, viewport, waitMs }, ctx) => {
199
+ try {
200
+ const run = await runMeasurement({
201
+ url,
202
+ workflow: WORKFLOWS[workflow],
203
+ rules: RULES,
204
+ waitMs,
205
+ viewport: VIEWPORT_PRESETS[viewport],
206
+ onProgress: progressFor(ctx),
207
+ });
208
+ // The runner explains a skipped step with the flag of the CLI; here the tools are what the model has
209
+ const results = run.results.map((r) =>
210
+ r.status === "skipped"
211
+ ? { ...r, reason: "This step measures clicks and key presses: open the page with open_page and this snippet in track, use it with interact, and read it with read_results." }
212
+ : r
213
+ );
214
+ return toolResult({ url, workflow, viewport, navMs: run.navMs, pageErrors: run.pageErrors, results });
215
+ } catch (err) {
216
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
217
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
218
+ return toolError(`The run failed: ${firstLine(err.message)}`);
219
+ }
220
+ }
221
+ );
222
+
223
+ server.registerTool(
224
+ "get_crux",
225
+ {
226
+ title: "Get CrUX field data",
227
+ description:
228
+ "Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get.",
229
+ inputSchema: z.object({
230
+ url: HttpUrl,
231
+ formFactor: z.enum(Object.keys(CRUX_FORM_FACTORS)).default("phone").describe("The devices the data describes"),
232
+ }),
233
+ outputSchema: z.object({
234
+ url: z.string(),
235
+ formFactor: z.string(),
236
+ scope: z.enum(["url", "origin"]).optional().describe("Whether the data is for the URL or for its whole origin"),
237
+ metrics: z
238
+ .record(z.string(), z.object({ p75: z.number(), rating: z.string(), unit: z.string() }))
239
+ .optional()
240
+ .describe("By metric name: LCP, CLS, INP"),
241
+ unavailable: z.string().optional().describe("Why there is no field data, such as a page with too little traffic"),
242
+ }),
243
+ annotations: { readOnlyHint: true, openWorldHint: true },
244
+ },
245
+ async ({ url, formFactor }) => {
246
+ const apiKey = env.CRUX_API_KEY;
247
+ if (!apiKey) return toolError(CRUX_SETUP);
248
+ const crux = await fetchCrux({
249
+ url,
250
+ apiKey,
251
+ formFactor: CRUX_FORM_FACTORS[formFactor],
252
+ ...(env.WEBPERF_CRUX_ENDPOINT ? { endpoint: env.WEBPERF_CRUX_ENDPOINT } : {}),
253
+ });
254
+ if (crux.failed) return toolError(`Could not read the CrUX data: ${crux.unavailable}.`);
255
+ if (crux.unavailable) return toolResult({ url, formFactor, unavailable: crux.unavailable });
256
+ return toolResult({ url, formFactor, scope: crux.scope, metrics: crux.metrics });
257
+ }
258
+ );
259
+
260
+ server.registerTool(
261
+ "open_page",
262
+ {
263
+ title: "Open a page",
264
+ description:
265
+ "Opens the URL in a headless Chromium that stays open between calls, and installs the snippets that record interactions (INP, Interactions, Input-Latency-Breakdown, and the other tracking snippets in list_snippets). Returns a sessionId for interact, read_results and close_page. At most 3 pages are open at a time, and a page closes after 10 minutes without use.",
266
+ inputSchema: z.object({
267
+ url: HttpUrl,
268
+ track: z.array(z.string()).min(1).describe("The tracking snippets to install, by name, alias or Category/Name"),
269
+ viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
270
+ waitMs: z.number().int().min(0).max(10000).default(1000).describe("Wait after the load event before installing the snippets, in ms"),
271
+ }),
272
+ outputSchema: z.object({
273
+ sessionId: z.string(),
274
+ url: z.string(),
275
+ viewport: z.enum(VIEWPORTS),
276
+ navMs: z.number(),
277
+ tracking: z.array(z.object({ snippet: z.string(), status: z.string() })).describe("The installed snippets"),
278
+ }),
279
+ annotations: { readOnlyHint: true, openWorldHint: true },
280
+ },
281
+ withSession(async ({ url, track, viewport, waitMs }, ctx) => {
282
+ const items = [];
283
+ for (const name of track) {
284
+ let path;
285
+ try {
286
+ path = resolveSnippetName(name);
287
+ } catch (err) {
288
+ return toolError(err.message);
289
+ }
290
+ const source = loadSnippet(path);
291
+ if (!isTrackingSnippet(path, source)) {
292
+ return toolError(`${path} does not record interactions: run it with run_snippet instead.`);
293
+ }
294
+ items.push({ id: path, path, source });
295
+ }
296
+
297
+ let session;
298
+ try {
299
+ session = await sessions.create({ viewport: VIEWPORT_PRESETS[viewport], signal: ctx.mcpReq.signal });
300
+ } catch (err) {
301
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
302
+ throw err;
303
+ }
304
+ try {
305
+ const navStart = Date.now();
306
+ await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
307
+ if (waitMs > 0) await session.page.waitForTimeout(waitMs);
308
+ const navMs = Date.now() - navStart;
309
+ session.tracking = await evaluateItems(session.page, items);
310
+ session.stopListening();
311
+ const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
312
+ return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
313
+ } catch (err) {
314
+ await sessions.close(session.id);
315
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
316
+ return toolError(`Opening the page failed: ${firstLine(err.message)}`);
317
+ }
318
+ })
319
+ );
320
+
321
+ server.registerTool(
322
+ "interact",
323
+ {
324
+ title: "Interact with an open page",
325
+ description:
326
+ "Runs steps on a page opened with open_page, as a person would: click, type, hover, scroll or wait. A click or a key press is what the tracking snippets record. A step whose element does not appear within 5 seconds stops the run.",
327
+ inputSchema: z.object({
328
+ sessionId: SessionId,
329
+ steps: z.array(Step).min(1).max(50),
330
+ }),
331
+ outputSchema: z.object({ sessionId: z.string(), steps: z.number().describe("Steps run"), url: z.string().describe("The URL after the steps") }),
332
+ // A click can submit a form or change data on the site, so this tool is not read-only
333
+ annotations: { readOnlyHint: false, openWorldHint: true },
334
+ },
335
+ withSession(async ({ sessionId, steps }) => {
336
+ const session = sessions.get(sessionId);
337
+ try {
338
+ await runInteractions(session.page, steps);
339
+ } catch (err) {
340
+ return toolError(`A step failed: ${firstLine(err.message)}`);
341
+ }
342
+ session.hadInput ||= steps.some(isInputStep);
343
+ await settle(session.page);
344
+ return toolResult({ sessionId, steps: steps.length, url: session.page.url() });
345
+ })
346
+ );
347
+
348
+ server.registerTool(
349
+ "read_results",
350
+ {
351
+ title: "Read the tracking snippets",
352
+ description:
353
+ "Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet answers with status error, which is a result, not a failure.",
354
+ inputSchema: z.object({ sessionId: SessionId }),
355
+ outputSchema: z.object({ sessionId: z.string(), pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
356
+ annotations: { readOnlyHint: true, openWorldHint: false },
357
+ },
358
+ withSession(async ({ sessionId }) => {
359
+ const session = sessions.get(sessionId);
360
+ const results = [];
361
+ for (const installed of session.tracking) {
362
+ const { id, ...result } = await collectTrackingData(session.page, installed, session.hadInput);
363
+ results.push(result);
364
+ }
365
+ return toolResult({ sessionId, pageErrors: session.pageErrors, results });
366
+ })
367
+ );
368
+
369
+ server.registerTool(
370
+ "close_page",
371
+ {
372
+ title: "Close a page",
373
+ description: "Closes a page opened with open_page. Closing a page that is already closed is not an error.",
374
+ inputSchema: z.object({ sessionId: SessionId }),
375
+ outputSchema: z.object({ sessionId: z.string(), closed: z.boolean().describe("The page was open and is now closed") }),
376
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
377
+ },
378
+ async ({ sessionId }) => toolResult({ sessionId, closed: await sessions.close(sessionId) })
379
+ );
380
+
381
+ return server;
382
+ }
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ // A failure of a session tool the model can act on: an unknown or closed page, or too many open pages
4
+ export class SessionError extends Error {}
5
+
6
+ const duration = (ms) => (ms >= 60000 ? `${ms / 60000} minutes` : `${ms / 1000} seconds`);
7
+
8
+ // The pages an MCP connection keeps open between calls. They share one Chromium, launched with the
9
+ // first page, and each page has its own browser context, so pages share no cookies or storage. A page
10
+ // left unused for `idleMs` closes on its own, and closeAll() closes every page and Chromium.
11
+ export function createSessions({ launchBrowser, maxPages = 3, idleMs = 10 * 60 * 1000 }) {
12
+ let browser = null;
13
+ const open = new Map();
14
+ const closedWhileIdle = new Set();
15
+ let opening = 0;
16
+
17
+ const ids = () => [...open.keys()].join(", ") || "none";
18
+
19
+ function touch(session) {
20
+ clearTimeout(session.timer);
21
+ session.timer = setTimeout(() => {
22
+ closedWhileIdle.add(session.id);
23
+ close(session.id);
24
+ }, idleMs);
25
+ // The timer alone must not keep the process alive
26
+ session.timer.unref?.();
27
+ }
28
+
29
+ // Opens a context and a page. An aborted signal (the client cancelled the call) closes the context,
30
+ // which makes any navigation in progress fail.
31
+ async function create({ viewport, signal }) {
32
+ if (open.size + opening >= maxPages) {
33
+ throw new SessionError(`${maxPages} pages are open already (${ids()}). Close one with close_page first.`);
34
+ }
35
+ opening++;
36
+ try {
37
+ // A launch that fails (Chromium not installed yet) is not kept: the next page launches again
38
+ browser ??= launchBrowser().catch((err) => {
39
+ browser = null;
40
+ throw err;
41
+ });
42
+ const context = await (await browser).newContext({ viewport });
43
+ const page = await context.newPage();
44
+ const session = { id: randomUUID(), context, page, pageErrors: [], tracking: [], hadInput: false };
45
+ page.on("pageerror", (err) => session.pageErrors.push(err.message));
46
+ // Actions on a page that is still open answer within seconds, not the 30 s of Playwright
47
+ page.setDefaultTimeout(5000);
48
+ const cancel = () => close(session.id);
49
+ signal?.addEventListener("abort", cancel, { once: true });
50
+ session.stopListening = () => signal?.removeEventListener("abort", cancel);
51
+ open.set(session.id, session);
52
+ touch(session);
53
+ return session;
54
+ } finally {
55
+ opening--;
56
+ }
57
+ }
58
+
59
+ function get(id) {
60
+ const session = open.get(id);
61
+ if (session) {
62
+ touch(session);
63
+ return session;
64
+ }
65
+ if (closedWhileIdle.has(id)) {
66
+ throw new SessionError(`The page ${id} was closed after ${duration(idleMs)} without use. Open it again with open_page.`);
67
+ }
68
+ throw new SessionError(`No open page has the id ${id} (open: ${ids()}). Open one with open_page.`);
69
+ }
70
+
71
+ async function close(id) {
72
+ const session = open.get(id);
73
+ if (!session) return false;
74
+ open.delete(id);
75
+ clearTimeout(session.timer);
76
+ session.stopListening();
77
+ await session.context.close().catch(() => {});
78
+ return true;
79
+ }
80
+
81
+ async function closeAll() {
82
+ await Promise.all([...open.keys()].map(close));
83
+ if (browser) {
84
+ const launched = browser;
85
+ browser = null;
86
+ await (await launched).close().catch(() => {});
87
+ }
88
+ }
89
+
90
+ return { create, get, close, closeAll };
91
+ }
@@ -0,0 +1,10 @@
1
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
2
+ import { createServer } from "./server.js";
3
+
4
+ // Serves the MCP server over stdio: the client starts this process and exchanges JSON-RPC messages,
5
+ // one per line, through its standard input and output. Standard output carries nothing else, so
6
+ // nothing here or in the code it calls may write to it. serveStdio answers both eras of the
7
+ // protocol, and decides which one when the client connects.
8
+ export function startStdioServer() {
9
+ return serveStdio(() => createServer());
10
+ }
@@ -0,0 +1,16 @@
1
+ // The domains of the site that differ from the root domain of the page, such as its own CDN,
2
+ // which the snippets that tell first from third parties count as first party. In the console
3
+ // they are written at the top of each snippet; the CLI writes them there before it runs it.
4
+ const DECLARATION = "const OWN_DOMAINS = [];";
5
+
6
+ export function withOwnDomains(source, ownDomains) {
7
+ if (!ownDomains?.length) return source;
8
+ return source.replace(DECLARATION, `const OWN_DOMAINS = ${JSON.stringify(ownDomains)};`);
9
+ }
10
+
11
+ // The values given to --own-domains, each one a domain or a comma-separated list, or null when
12
+ // one of the domains is empty or has spaces. The snippets accept URLs and any case.
13
+ export function parseOwnDomains(values) {
14
+ const domains = values.flatMap((value) => value.split(",").map((domain) => domain.trim()));
15
+ return domains.every((domain) => domain !== "" && !/\s/.test(domain)) ? domains : null;
16
+ }
@@ -1,5 +1,6 @@
1
1
  import { styleText } from "node:util";
2
2
  import { fieldDataView } from "../crux.js";
3
+ import { describeThrottling } from "../throttling.js";
3
4
 
4
5
  const RATING_ICON = {
5
6
  good: "🟢",
@@ -169,7 +170,11 @@ function renderResult(r, verbose) {
169
170
  out += rows
170
171
  .map(([name, info]) => {
171
172
  const status = info.overTarget ? styleText("red", "🔴") : styleText("green", "✓");
172
- return ` ${status} ${pad(name, 22)} ${pad(`${info.value}ms`, 8)} ${pad(`${info.percent}%`, 6)}`;
173
+ const row = ` ${status} ${pad(name, 22)} ${pad(`${info.value}ms`, 8)} ${pad(`${info.percent}%`, 6)}`;
174
+ // The Resource Load Time splits into the wait for the server and the download, when the snippet measures it
175
+ return info.waiting == null
176
+ ? row
177
+ : `${row}\n ${styleText("dim", `${info.waiting}ms waiting for the server, ${info.download}ms downloading`)}`;
173
178
  })
174
179
  .join("\n");
175
180
  if (r.details.slowestPhase) {
@@ -198,10 +203,11 @@ function renderFieldData(crux, results) {
198
203
  return lines;
199
204
  }
200
205
 
201
- export function reportHuman({ url, navMs, results, pageErrors, verbose, crux }) {
206
+ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling }) {
202
207
  const lines = [];
203
208
  lines.push(styleText(["bold"], `WebPerf Snippets — ${url}`));
204
209
  lines.push(styleText("dim", `Navigated in ${navMs}ms`));
210
+ if (throttling) lines.push(styleText("dim", `Throttling: ${describeThrottling(throttling)}`));
205
211
  lines.push("");
206
212
  for (const r of results) lines.push(renderResult(r, verbose));
207
213
  if (crux) {
@@ -1,4 +1,5 @@
1
1
  import { fieldDataView } from "../crux.js";
2
+ import { describeThrottling } from "../throttling.js";
2
3
 
3
4
  const RATING_ICON = {
4
5
  good: "✅",
@@ -71,10 +72,11 @@ function renderFieldData(crux, results) {
71
72
  ];
72
73
  }
73
74
 
74
- export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
75
+ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling }) {
75
76
  const lines = [];
76
77
  lines.push(`## WebPerf Results — ${url}`);
77
78
  lines.push(`> Navigated in ${navMs}ms`);
79
+ if (throttling) lines.push(">", `> Throttling: ${describeThrottling(throttling)}`);
78
80
  lines.push("");
79
81
 
80
82
  const initial = results.filter((r) => !r.reason);