@vercel/next-browser 0.1.0 → 0.1.1

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/browser.ts DELETED
@@ -1,408 +0,0 @@
1
- /**
2
- * Browser manager — single headed Chromium instance with React DevTools.
3
- *
4
- * Launches via Playwright with the React DevTools Chrome extension pre-loaded
5
- * and --auto-open-devtools-for-tabs so the extension activates naturally.
6
- * installHook.js is pre-injected via addInitScript to win the race against
7
- * the extension's content script registration.
8
- *
9
- * Module-level state: one browser context, one page, one PPR lock.
10
- */
11
-
12
- import { readFileSync } from "node:fs";
13
- import { join, resolve } from "node:path";
14
- import { chromium, type BrowserContext, type Page } from "playwright";
15
- import { instant } from "@next/playwright";
16
- import * as componentTree from "./tree.ts";
17
- import * as suspenseTree from "./suspense.ts";
18
- import * as sourcemap from "./sourcemap.ts";
19
- import * as nextMcp from "./mcp.ts";
20
- import * as net from "./network.ts";
21
-
22
- // React DevTools extension — vendored or overridden via env var.
23
- const extensionPath =
24
- process.env.REACT_DEVTOOLS_EXTENSION ??
25
- resolve(import.meta.dirname, "../extensions/react-devtools-chrome");
26
-
27
- // Pre-read the hook script so it's ready for addInitScript on launch.
28
- const installHook = readFileSync(
29
- join(extensionPath, "build", "installHook.js"),
30
- "utf-8",
31
- );
32
-
33
- let context: BrowserContext | null = null;
34
- let page: Page | null = null;
35
-
36
- // ── Browser lifecycle ────────────────────────────────────────────────────────
37
-
38
- /**
39
- * Launch the browser (if not already open) and optionally navigate to a URL.
40
- * The first call spawns Chromium with the DevTools extension; subsequent calls
41
- * reuse the existing context.
42
- */
43
- export async function open(url: string | undefined) {
44
- if (!context) {
45
- context = await launch();
46
- page = context.pages()[0] ?? (await context.newPage());
47
- net.attach(page);
48
- }
49
- if (url) {
50
- await page!.goto(url, { waitUntil: "domcontentloaded" });
51
- }
52
- }
53
-
54
- /**
55
- * Set cookies on the browser context. Must be called after open() but before
56
- * navigating to the target page, so the cookies are present on the first request.
57
- * Accepts the same {name, value}[] format as ppr-optimizer's AUTH_COOKIES.
58
- */
59
- export async function cookies(cookies: { name: string; value: string }[], domain: string) {
60
- if (!context) throw new Error("browser not open");
61
- await context.addCookies(
62
- cookies.map((c) => ({ name: c.name, value: c.value, domain, path: "/" })),
63
- );
64
- return cookies.length;
65
- }
66
-
67
- /** Close the browser and reset all state. */
68
- export async function close() {
69
- await context?.close();
70
- context = null;
71
- page = null;
72
- release = null;
73
- settled = null;
74
- }
75
-
76
- // ── PPR lock/unlock ──────────────────────────────────────────────────────────
77
- //
78
- // The lock uses @next/playwright's `instant()` which sets the
79
- // `next-instant-navigation-testing=1` cookie. While locked:
80
- // - goto: server sends the raw PPR shell (static HTML + <template> holes)
81
- // - push: Next.js router blocks dynamic data writes, shows prefetched shell
82
- //
83
- // The lock is held by stashing instant()'s inner promise resolver (`release`).
84
- // Calling unlock() resolves it, which lets instant() finish and clear the cookie.
85
-
86
- let release: (() => void) | null = null;
87
- let settled: Promise<void> | null = null;
88
-
89
- /** Enter PPR instant-navigation mode. The cookie is set immediately. */
90
- export function lock() {
91
- if (!page) throw new Error("browser not open");
92
- if (release) throw new Error("already locked");
93
-
94
- return new Promise<void>((locked) => {
95
- settled = instant(page!, () => {
96
- locked();
97
- return new Promise<void>((r) => (release = r));
98
- });
99
- });
100
- }
101
-
102
- /**
103
- * Exit PPR mode and produce a shell analysis report.
104
- *
105
- * Two-phase capture:
106
- * 1. LOCKED snapshot — which boundaries are currently suspended (= holes in the shell).
107
- * Waits for the suspended count to stabilize first, so fast-resolving boundaries
108
- * (e.g. a feature flag guard that completes in <100ms) don't get falsely reported.
109
- * Falls back to counting <template id="B:..."> DOM elements for the goto case
110
- * where React DevTools can't inspect the production-like shell.
111
- *
112
- * 2. Release the lock. For push: dynamic content streams in (no reload).
113
- * For goto: cookie cleared → page auto-reloads.
114
- *
115
- * 3. UNLOCKED snapshot — all boundaries resolved, with full suspendedBy data
116
- * (what blocked each one: hooks, server calls, cache, scripts, etc.)
117
- *
118
- * 4. Match locked holes against unlocked data by JSX source location,
119
- * producing the final "Dynamic holes / Static" report.
120
- */
121
- export async function unlock() {
122
- if (!release) return null;
123
- if (!page) return null;
124
-
125
- const origin = new URL(page.url()).origin;
126
-
127
- // Wait for the suspended boundary count to stop changing. This filters out
128
- // boundaries that suspend briefly then resolve (e.g. fast flag checks) —
129
- // only truly stuck boundaries remain as "holes."
130
- await stabilizeSuspenseState(page);
131
-
132
- // Capture what's suspended right now under the lock.
133
- let locked = await suspenseTree.snapshot(page).catch(() => [] as suspenseTree.Boundary[]);
134
-
135
- // For initial-load (goto) under lock, DevTools may not be connected —
136
- // the shell uses a production-like renderer. Fall back to counting
137
- // <template id="B:..."> elements in the DOM (PPR's Suspense placeholders).
138
- const hasDevToolsData = locked.some((b) => b.parentID !== 0);
139
- if (!hasDevToolsData) {
140
- locked = await suspenseTree.snapshotFromDom(page);
141
- }
142
-
143
- // Release the lock. instant() clears the cookie.
144
- // - push case: dynamic content streams in immediately (no reload)
145
- // - goto case: cookieStore change → auto-reload → full page load
146
- release();
147
- release = null;
148
- await settled;
149
- settled = null;
150
-
151
- // Wait for all boundaries to resolve after unlock.
152
- // Polls the DevTools suspense tree (works for both push and goto cases).
153
- await waitForSuspenseToSettle(page);
154
-
155
- // Capture the fully-resolved state with rich suspendedBy data.
156
- const unlocked = await suspenseTree.snapshot(page).catch(() => [] as suspenseTree.Boundary[]);
157
-
158
- if (locked.length === 0 && unlocked.length === 0) return null;
159
- return suspenseTree.formatAnalysis(unlocked, locked, origin);
160
- }
161
-
162
- /**
163
- * Wait for the suspended boundary count to stop changing.
164
- *
165
- * Polls every 300ms. Returns once two consecutive polls show the same
166
- * suspended count. This lets fast-resolving boundaries (feature flag guards,
167
- * instant cache hits) settle before we snapshot — preventing false positives
168
- * where a boundary appears as a "hole" but resolves before the shell paints.
169
- */
170
- async function stabilizeSuspenseState(p: Page) {
171
- const deadline = Date.now() + 5_000;
172
- let lastSuspended = -1;
173
- await new Promise((r) => setTimeout(r, 300));
174
- while (Date.now() < deadline) {
175
- const { suspended } = await suspenseTree.countBoundaries(p);
176
- if (suspended === lastSuspended) return;
177
- lastSuspended = suspended;
178
- await new Promise((r) => setTimeout(r, 300));
179
- }
180
- }
181
-
182
- /**
183
- * Wait for all Suspense boundaries to resolve after unlock.
184
- *
185
- * Used after releasing the PPR lock. For push: dynamic content streams in
186
- * via JS. For goto: the page auto-reloads after the cookie clears.
187
- * In both cases, we poll the DevTools suspense tree until no boundaries
188
- * are suspended (or timeout after 10s).
189
- *
190
- * Tracks whether we've ever seen boundaries — if DevTools never reports any
191
- * (e.g. during a goto reload where it takes time to reconnect), we wait up
192
- * to 5s for them to appear before giving up.
193
- */
194
- async function waitForSuspenseToSettle(p: Page) {
195
- const deadline = Date.now() + 10_000;
196
- await new Promise((r) => setTimeout(r, 500));
197
- let sawBoundaries = false;
198
- while (Date.now() < deadline) {
199
- const { total, suspended } = await suspenseTree.countBoundaries(p);
200
- if (total > 0) {
201
- sawBoundaries = true;
202
- if (suspended === 0) return;
203
- } else if (!sawBoundaries && Date.now() > deadline - 5000) {
204
- return;
205
- }
206
- await new Promise((r) => setTimeout(r, 500));
207
- }
208
- }
209
-
210
- // ── Navigation ───────────────────────────────────────────────────────────────
211
-
212
- /** Hard reload the current page. Returns the URL after reload. */
213
- export async function reload() {
214
- if (!page) throw new Error("browser not open");
215
- await page.reload({ waitUntil: "domcontentloaded" });
216
- return page.url();
217
- }
218
-
219
- /**
220
- * Restart the Next.js dev server via its internal endpoint, then reload.
221
- * Polls /__nextjs_server_status until the executionId changes (new process).
222
- */
223
- export async function restart() {
224
- if (!page) throw new Error("browser not open");
225
- const origin = new URL(page.url()).origin;
226
-
227
- const before = await executionId(origin);
228
-
229
- const url = `${origin}/__nextjs_restart_dev?invalidateFileSystemCache=1`;
230
- await fetch(url, { method: "POST" }).catch(() => {});
231
-
232
- const deadline = Date.now() + 30_000;
233
- while (Date.now() < deadline) {
234
- await new Promise((r) => setTimeout(r, 1_000));
235
- const after = await executionId(origin).catch(() => null);
236
- if (after != null && after !== before) break;
237
- }
238
-
239
- await page.reload({ waitUntil: "domcontentloaded" });
240
- return page.url();
241
- }
242
-
243
- async function executionId(origin: string) {
244
- const res = await fetch(`${origin}/__nextjs_server_status`);
245
- const data = (await res.json()) as { executionId: number };
246
- return data.executionId;
247
- }
248
-
249
- /**
250
- * Collect all same-origin <a> links on the current page.
251
- * Used by the interactive `push` picker — shows what routes are navigable
252
- * from the current page (i.e. have <Link> components that trigger prefetch).
253
- */
254
- export async function links() {
255
- if (!page) throw new Error("browser not open");
256
- return page.evaluate(() => {
257
- const origin = location.origin;
258
- const seen = new Set<string>();
259
- const results: { href: string; text: string }[] = [];
260
- for (const a of document.querySelectorAll("a[href]")) {
261
- const url = new URL(a.getAttribute("href")!, location.href);
262
- if (url.origin !== origin) continue;
263
- const path = url.pathname + url.search + url.hash;
264
- if (seen.has(path) || path === location.pathname) continue;
265
- seen.add(path);
266
- const text = (a.textContent || "").trim().slice(0, 80);
267
- results.push({ href: path, text });
268
- }
269
- return results;
270
- });
271
- }
272
-
273
- /**
274
- * Client-side navigation via Next.js router.push().
275
- * Requires the target route to be prefetched (a <Link> must exist on the
276
- * current page pointing to it). If the route isn't prefetched, push silently
277
- * fails and returns the current URL unchanged.
278
- */
279
- export async function push(path: string) {
280
- if (!page) throw new Error("browser not open");
281
- const before = page.url();
282
- await page.evaluate((p) => (window as any).next.router.push(p), path);
283
- await page.waitForURL((u) => u.href !== before, { timeout: 10_000 }).catch(() => {});
284
- return page.url();
285
- }
286
-
287
- /** Full-page navigation (new document load). Resolves relative URLs against the current page. */
288
- export async function goto(url: string) {
289
- if (!page) throw new Error("browser not open");
290
- const target = new URL(url, page.url()).href;
291
- await page.goto(target, { waitUntil: "domcontentloaded" });
292
- return target;
293
- }
294
-
295
- /** Go back in browser history. */
296
- export async function back() {
297
- if (!page) throw new Error("browser not open");
298
- await page.goBack({ waitUntil: "domcontentloaded" });
299
- }
300
-
301
- // ── React component tree ─────────────────────────────────────────────────────
302
-
303
- let lastSnapshot: componentTree.Node[] = [];
304
-
305
- /**
306
- * Get the full React component tree via DevTools' flushInitialOperations().
307
- * Decodes TREE_OPERATION_ADD entries from the operations wire format into
308
- * a flat node list with depth/id/parent/name columns.
309
- */
310
- export async function tree() {
311
- if (!page) throw new Error("browser not open");
312
- lastSnapshot = await componentTree.snapshot(page);
313
- return componentTree.format(lastSnapshot);
314
- }
315
-
316
- /**
317
- * Inspect a single component by fiber ID. Returns props, hooks, state,
318
- * ownership chain, and source-mapped file location. Uses the last tree
319
- * snapshot to build the ancestor path.
320
- */
321
- export async function node(id: number) {
322
- if (!page) throw new Error("browser not open");
323
- const { text, source } = await componentTree.inspect(page, id);
324
-
325
- const lines: string[] = [];
326
- const path = componentTree.path(lastSnapshot, id);
327
- if (path) lines.push(`path: ${path}`);
328
- lines.push(text);
329
- if (source) lines.push(await formatSource(source));
330
-
331
- return lines.join("\n");
332
- }
333
-
334
- /**
335
- * Resolve a bundled source location to its original file via source maps.
336
- * Tries the Next.js dev server endpoint first (resolves user code),
337
- * then falls back to fetching .map files directly (handles node_modules).
338
- */
339
- async function formatSource([file, line, col]: [string, number, number]) {
340
- const origin = new URL(page!.url()).origin;
341
-
342
- const resolved = await sourcemap.resolve(origin, file, line, col);
343
- if (resolved) return `source: ${resolved.file}:${resolved.line}:${resolved.column}`;
344
-
345
- const viaMap = await sourcemap.resolveViaMap(origin, file, line, col);
346
- if (viaMap) return `source: ${viaMap.file}:${viaMap.line}:${viaMap.column}`;
347
-
348
- return `source: ${file}:${line}:${col}`;
349
- }
350
-
351
- // ── Utilities ────────────────────────────────────────────────────────────────
352
-
353
- /** Full-page screenshot saved to a temp file. Returns the file path. */
354
- export async function screenshot() {
355
- if (!page) throw new Error("browser not open");
356
- const { join } = await import("node:path");
357
- const { tmpdir } = await import("node:os");
358
- const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
359
- await page.screenshot({ path, fullPage: true });
360
- return path;
361
- }
362
-
363
- /** Evaluate arbitrary JavaScript in the page context. */
364
- export async function evaluate(script: string) {
365
- if (!page) throw new Error("browser not open");
366
- return page.evaluate(script);
367
- }
368
-
369
- /** Call a Next.js dev server MCP tool (JSON-RPC over SSE at /_next/mcp). */
370
- export async function mcp(tool: string, args?: Record<string, unknown>) {
371
- if (!page) throw new Error("browser not open");
372
- const origin = new URL(page.url()).origin;
373
- return nextMcp.call(origin, tool, args);
374
- }
375
-
376
- /** Get network request log, or detail for a specific request index. */
377
- export function network(idx?: number) {
378
- return idx == null ? net.format() : net.detail(idx);
379
- }
380
-
381
- // ── Browser launch ───────────────────────────────────────────────────────────
382
-
383
- /**
384
- * Launch Chromium with React DevTools extension.
385
- *
386
- * - launchPersistentContext("") — empty user data dir (fresh profile each time)
387
- * - --load-extension loads the vendored React DevTools Chrome extension
388
- * - --auto-open-devtools-for-tabs makes the extension activate its backend
389
- * on every tab (same as a developer manually opening DevTools)
390
- * - waitForEvent("serviceworker") ensures the extension's background script
391
- * is running before we navigate
392
- * - addInitScript(installHook) injects the DevTools hook before any page JS,
393
- * winning the race against the extension's content script
394
- */
395
- async function launch() {
396
- const ctx = await chromium.launchPersistentContext("", {
397
- headless: false,
398
- viewport: null,
399
- args: [
400
- `--disable-extensions-except=${extensionPath}`,
401
- `--load-extension=${extensionPath}`,
402
- "--auto-open-devtools-for-tabs",
403
- ],
404
- });
405
- await ctx.waitForEvent("serviceworker");
406
- await ctx.addInitScript(installHook);
407
- return ctx;
408
- }
package/src/cli.ts DELETED
@@ -1,233 +0,0 @@
1
- #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import { send } from "./client.ts";
4
-
5
- const args = process.argv.slice(2);
6
- const cmd = args[0];
7
- const arg = args[1];
8
-
9
- if (cmd === "--help" || cmd === "-h" || !cmd) {
10
- printUsage();
11
- process.exit(0);
12
- }
13
-
14
- if (cmd === "open") {
15
- if (!arg) {
16
- console.error("usage: next-browser open <url> [--cookies-json <file>]");
17
- process.exit(1);
18
- }
19
- const cookieIdx = args.indexOf("--cookies-json");
20
- const cookieFile = cookieIdx >= 0 ? args[cookieIdx + 1] : undefined;
21
-
22
- if (cookieFile) {
23
- const res = await send("open");
24
- if (!res.ok) exit(res, "");
25
- const raw = readFileSync(cookieFile, "utf-8");
26
- const cookies = JSON.parse(raw);
27
- const domain = new URL(arg).hostname;
28
- const cRes = await send("cookies", { cookies, domain });
29
- if (!cRes.ok) exit(cRes, "");
30
- await send("goto", { url: arg });
31
- exit(res, `opened → ${arg} (${cookies.length} cookies for ${domain})`);
32
- }
33
-
34
- const res = await send("open", { url: arg });
35
- exit(res, `opened → ${arg}`);
36
- }
37
-
38
- if (cmd === "ppr" && arg === "lock") {
39
- const res = await send("lock");
40
- exit(res, "locked");
41
- }
42
-
43
- if (cmd === "ppr" && arg === "unlock") {
44
- const res = await send("unlock");
45
- exit(res, res.ok && res.data ? `unlocked\n\n${res.data}` : "unlocked");
46
- }
47
-
48
- if (cmd === "reload") {
49
- const res = await send("reload");
50
- exit(res, res.ok ? `reloaded → ${res.data}` : "");
51
- }
52
-
53
- if (cmd === "restart-server") {
54
- const res = await send("restart");
55
- exit(res, res.ok ? `restarted → ${res.data}` : "");
56
- }
57
-
58
- if (cmd === "push") {
59
- if (arg) {
60
- const res = await send("push", { url: arg });
61
- exit(res, res.ok ? `→ ${res.data}` : "");
62
- }
63
- const linksRes = await send("links");
64
- if (!linksRes.ok) exit(linksRes, "");
65
- const links = linksRes.data as { href: string; text: string }[];
66
- if (links.length === 0) {
67
- console.error("no links on current page");
68
- process.exit(1);
69
- }
70
- const picked = await pick(links.map((l) => `${l.href} ${l.text}`));
71
- const res = await send("push", { url: links[picked].href });
72
- exit(res, res.ok ? `→ ${res.data}` : "");
73
- }
74
-
75
- if (cmd === "goto") {
76
- const res = await send("goto", { url: arg });
77
- exit(res, res.ok ? `→ ${res.data}` : "");
78
- }
79
-
80
- if (cmd === "back") {
81
- const res = await send("back");
82
- exit(res, "back");
83
- }
84
-
85
- if (cmd === "screenshot") {
86
- const res = await send("screenshot");
87
- exit(res, res.ok ? String(res.data) : "");
88
- }
89
-
90
- if (cmd === "eval") {
91
- if (!arg) {
92
- console.error("usage: next-browser eval <script>");
93
- process.exit(1);
94
- }
95
- const res = await send("eval", { script: arg });
96
- exit(res, res.ok ? json(res.data) : "");
97
- }
98
-
99
- if (cmd === "tree") {
100
- const res = arg != null
101
- ? await send("node", { nodeId: Number(arg) })
102
- : await send("tree");
103
- exit(res, res.ok ? String(res.data) : "");
104
- }
105
-
106
- const mcpTools: Record<string, string> = {
107
- errors: "get_errors",
108
- page: "get_page_metadata",
109
- project: "get_project_metadata",
110
- routes: "get_routes",
111
- };
112
-
113
- if (cmd in mcpTools) {
114
- const res = await send("mcp", { tool: mcpTools[cmd] });
115
- exit(res, res.ok ? json(res.data) : "");
116
- }
117
-
118
- if (cmd === "logs") {
119
- const res = await send("mcp", { tool: "get_logs" });
120
- if (!res.ok) exit(res, "");
121
- const data = res.data as { logFilePath?: string };
122
- if (!data?.logFilePath) exit(res, json(data));
123
- const content = readTail(data.logFilePath, 100);
124
- console.log(content || "(log file is empty)");
125
- process.exit(0);
126
- }
127
-
128
- if (cmd === "action") {
129
- const res = await send("mcp", { tool: "get_server_action_by_id", args: { actionId: arg } });
130
- exit(res, res.ok ? json(res.data) : "");
131
- }
132
-
133
- if (cmd === "network") {
134
- const res = await send("network", arg != null ? { idx: Number(arg) } : {});
135
- exit(res, res.ok ? String(res.data) : "");
136
- }
137
-
138
- if (cmd === "close") {
139
- const res = await send("close");
140
- exit(res, "closed");
141
- }
142
-
143
- console.error(`unknown command: ${cmd}\n`);
144
- printUsage();
145
- process.exit(1);
146
-
147
- function exit(res: { ok: true; data?: unknown } | { ok: false; error: string }, message: string): never {
148
- if (res.ok) {
149
- console.log(message);
150
- process.exit(0);
151
- }
152
- console.error(`error: ${res.error}`);
153
- process.exit(1);
154
- }
155
-
156
- function json(data: unknown) {
157
- return JSON.stringify(data, null, 2);
158
- }
159
-
160
- function readTail(path: string, lines: number): string {
161
- try {
162
- const content = readFileSync(path, "utf-8");
163
- const all = content.split("\n");
164
- return all.slice(-lines).join("\n").trim();
165
- } catch {
166
- return `(could not read ${path})`;
167
- }
168
- }
169
-
170
- function pick(items: string[]): Promise<number> {
171
- return new Promise((resolve) => {
172
- let idx = 0;
173
- const render = () => {
174
- process.stdout.write("\x1B[?25l");
175
- for (let i = 0; i < items.length; i++) {
176
- if (i > 0) process.stdout.write("\n");
177
- process.stdout.write(i === idx ? `\x1B[36m❯ ${items[i]}\x1B[0m` : ` ${items[i]}`);
178
- }
179
- process.stdout.write(`\x1B[${items.length - 1}A\r`);
180
- };
181
- render();
182
- process.stdin.setRawMode(true);
183
- process.stdin.resume();
184
- process.stdin.on("data", (key: Buffer) => {
185
- const k = key.toString();
186
- if (k === "\x1B[A" && idx > 0) { idx--; process.stdout.write(`\r\x1B[J`); render(); }
187
- else if (k === "\x1B[B" && idx < items.length - 1) { idx++; process.stdout.write(`\r\x1B[J`); render(); }
188
- else if (k === "\r" || k === "\n") {
189
- process.stdin.setRawMode(false);
190
- process.stdin.pause();
191
- process.stdout.write(`\r\x1B[J\x1B[?25h`);
192
- resolve(idx);
193
- }
194
- else if (k === "\x03" || k === "q") {
195
- process.stdout.write(`\r\x1B[J\x1B[?25h`);
196
- process.exit(0);
197
- }
198
- });
199
- });
200
- }
201
-
202
- function printUsage() {
203
- console.error(
204
- "usage: next-browser <command> [args]\n" +
205
- "\n" +
206
- " open <url> [--cookies-json <file>] launch browser and navigate\n" +
207
- " close close browser and daemon\n" +
208
- "\n" +
209
- " goto <url> full-page navigation (new document load)\n" +
210
- " push [path] client-side navigation (interactive picker if no path)\n" +
211
- " back go back in history\n" +
212
- " reload reload current page\n" +
213
- " restart-server restart the Next.js dev server (clears fs cache)\n" +
214
- "\n" +
215
- " ppr lock enter PPR instant-navigation mode\n" +
216
- " ppr unlock exit PPR mode and show shell analysis\n" +
217
- "\n" +
218
- " tree show React component tree\n" +
219
- " tree <id> inspect component (props, hooks, state, source)\n" +
220
- "\n" +
221
- " screenshot save full-page screenshot to tmp file\n" +
222
- " eval <script> evaluate JS in page context\n" +
223
- "\n" +
224
- " errors show build/runtime errors\n" +
225
- " logs show recent dev server log output\n" +
226
- " network [idx] list network requests, or inspect one\n" +
227
- "\n" +
228
- " page show current page segments and router info\n" +
229
- " project show project path and dev server url\n" +
230
- " routes list app routes\n" +
231
- " action <id> inspect a server action by id",
232
- );
233
- }