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