@vercel/next-browser 0.1.2 → 0.1.4
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 +35 -13
- package/dist/browser.js +146 -4
- package/dist/cli.js +34 -0
- package/dist/daemon.js +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,25 +10,45 @@ Built for agents. An LLM can't read a DevTools panel, but it can run
|
|
|
10
10
|
command is a stateless one-shot against a long-lived browser daemon, so an
|
|
11
11
|
agent loop can fire them off without managing browser lifecycle.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Getting started
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
pnpm add -g @vercel/next-browser
|
|
17
|
-
```
|
|
15
|
+
You don't install or run this directly. Your agent does.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
1. **Add the skill to your project.** From your Next.js repo:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx skills add vercel-labs/next-browser
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Works with Claude Code, Cursor, Cline, and [others](https://skills.sh).
|
|
24
|
+
|
|
25
|
+
2. **Start your agent** in that project.
|
|
26
|
+
|
|
27
|
+
3. **Type `/next-browser`** to invoke the skill.
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
4. The skill checks for the CLI and **installs `@vercel/next-browser`
|
|
30
|
+
globally** if it's missing (plus `playwright install chromium`).
|
|
31
|
+
|
|
32
|
+
5. It asks for your dev server URL and any cookies it needs, opens the
|
|
33
|
+
browser, and from there it's **pair programming** — tell it what you're
|
|
34
|
+
debugging and it drives the tree, navigates pages, inspects components,
|
|
35
|
+
and reads errors for you.
|
|
36
|
+
|
|
37
|
+
That's the whole flow. Run `npx skills upgrade` later to pull updates.
|
|
38
|
+
|
|
39
|
+
The rest of this README documents the raw CLI for the rare case where you're
|
|
40
|
+
scripting it yourself.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Manual install
|
|
22
45
|
|
|
23
46
|
```bash
|
|
24
|
-
next-browser
|
|
25
|
-
next-browser tree
|
|
26
|
-
next-browser ppr lock
|
|
27
|
-
next-browser push /dashboard
|
|
28
|
-
next-browser ppr unlock
|
|
29
|
-
next-browser close
|
|
47
|
+
pnpm add -g @vercel/next-browser
|
|
30
48
|
```
|
|
31
49
|
|
|
50
|
+
Requires Node `>=20`.
|
|
51
|
+
|
|
32
52
|
## Commands
|
|
33
53
|
|
|
34
54
|
```
|
|
@@ -39,14 +59,16 @@ goto <url> full-page navigation (new document load)
|
|
|
39
59
|
push [path] client-side navigation (interactive picker if no path)
|
|
40
60
|
back go back in history
|
|
41
61
|
reload reload current page
|
|
62
|
+
capture-goto [url] capture loading sequence (PPR shell → hydration → data)
|
|
42
63
|
restart-server restart the Next.js dev server (clears fs cache)
|
|
43
64
|
|
|
44
|
-
ppr lock enter PPR instant-navigation mode
|
|
65
|
+
ppr lock enter PPR instant-navigation mode (requires cacheComponents)
|
|
45
66
|
ppr unlock exit PPR mode and show shell analysis
|
|
46
67
|
|
|
47
68
|
tree show React component tree
|
|
48
69
|
tree <id> inspect component (props, hooks, state, source)
|
|
49
70
|
|
|
71
|
+
viewport [WxH] show or set viewport size (e.g. 1280x720)
|
|
50
72
|
screenshot save full-page screenshot to tmp file
|
|
51
73
|
eval <script> evaluate JS in page context
|
|
52
74
|
|
package/dist/browser.js
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Launches via Playwright with the React DevTools Chrome extension pre-loaded
|
|
5
5
|
* and --auto-open-devtools-for-tabs so the extension activates naturally.
|
|
6
|
+
* DevTools opens in a separate (undocked) window so the main browser viewport
|
|
7
|
+
* stays at full desktop size.
|
|
6
8
|
* installHook.js is pre-injected via addInitScript to win the race against
|
|
7
9
|
* the extension's content script registration.
|
|
8
10
|
*
|
|
9
11
|
* Module-level state: one browser context, one page, one PPR lock.
|
|
10
12
|
*/
|
|
11
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
12
14
|
import { join, resolve } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
13
16
|
import { chromium } from "playwright";
|
|
14
17
|
import { instant } from "@next/playwright";
|
|
15
18
|
import * as componentTree from "./tree.js";
|
|
@@ -24,6 +27,7 @@ const extensionPath = process.env.REACT_DEVTOOLS_EXTENSION ??
|
|
|
24
27
|
const installHook = readFileSync(join(extensionPath, "build", "installHook.js"), "utf-8");
|
|
25
28
|
let context = null;
|
|
26
29
|
let page = null;
|
|
30
|
+
let profileDirPath = null;
|
|
27
31
|
// ── Browser lifecycle ────────────────────────────────────────────────────────
|
|
28
32
|
/**
|
|
29
33
|
* Launch the browser (if not already open) and optionally navigate to a URL.
|
|
@@ -58,6 +62,12 @@ export async function close() {
|
|
|
58
62
|
page = null;
|
|
59
63
|
release = null;
|
|
60
64
|
settled = null;
|
|
65
|
+
// Clean up temp profile directory.
|
|
66
|
+
if (profileDirPath) {
|
|
67
|
+
const { rmSync } = await import("node:fs");
|
|
68
|
+
rmSync(profileDirPath, { recursive: true, force: true });
|
|
69
|
+
profileDirPath = null;
|
|
70
|
+
}
|
|
61
71
|
}
|
|
62
72
|
// ── PPR lock/unlock ──────────────────────────────────────────────────────────
|
|
63
73
|
//
|
|
@@ -194,6 +204,67 @@ export async function reload() {
|
|
|
194
204
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
195
205
|
return page.url();
|
|
196
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Reload the page while capturing screenshots every ~150ms.
|
|
209
|
+
* Stops when: load has fired AND no new layout-shift entries for 2s.
|
|
210
|
+
* Hard timeout at 30s. Returns the list of screenshot paths plus any
|
|
211
|
+
* LayoutShift entries observed during the reload.
|
|
212
|
+
*/
|
|
213
|
+
/**
|
|
214
|
+
* Lock PPR → goto → screenshot the shell → unlock → screenshot frames
|
|
215
|
+
* until the page settles. Just PNGs in a directory — the AI reads them.
|
|
216
|
+
*
|
|
217
|
+
* Frame 0 is always the PPR shell. Remaining frames capture the transition
|
|
218
|
+
* through hydration and data loading. Stops after 3s of no visual change.
|
|
219
|
+
*/
|
|
220
|
+
export async function captureGoto(url) {
|
|
221
|
+
if (!page)
|
|
222
|
+
throw new Error("browser not open");
|
|
223
|
+
const targetUrl = url || page.url();
|
|
224
|
+
const dir = join(tmpdir(), `next-browser-capture-goto-${Date.now()}`);
|
|
225
|
+
mkdirSync(dir, { recursive: true });
|
|
226
|
+
let frameIdx = 0;
|
|
227
|
+
async function snap() {
|
|
228
|
+
const path = join(dir, `frame-${String(frameIdx).padStart(4, "0")}.png`);
|
|
229
|
+
const buf = await page.screenshot({ path }).catch(() => null);
|
|
230
|
+
frameIdx++;
|
|
231
|
+
return buf;
|
|
232
|
+
}
|
|
233
|
+
// PPR shell: lock suppresses hydration.
|
|
234
|
+
await lock();
|
|
235
|
+
await page.goto(targetUrl, { waitUntil: "load" }).catch(() => { });
|
|
236
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
237
|
+
await snap();
|
|
238
|
+
// Unlock → page reloads, hydrates, loads data.
|
|
239
|
+
const unlockDone = unlock();
|
|
240
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
241
|
+
let lastChangeTime = Date.now();
|
|
242
|
+
let prevHash = "";
|
|
243
|
+
const SETTLE_MS = 3_000;
|
|
244
|
+
const HARD_TIMEOUT = 30_000;
|
|
245
|
+
const start = Date.now();
|
|
246
|
+
while (true) {
|
|
247
|
+
const buf = await snap();
|
|
248
|
+
let hash = "";
|
|
249
|
+
if (buf) {
|
|
250
|
+
let h = 0;
|
|
251
|
+
for (let i = 0; i < buf.length; i += 200)
|
|
252
|
+
h = ((h << 5) - h + buf[i]) | 0;
|
|
253
|
+
hash = String(h);
|
|
254
|
+
}
|
|
255
|
+
if (hash !== prevHash) {
|
|
256
|
+
lastChangeTime = Date.now();
|
|
257
|
+
prevHash = hash;
|
|
258
|
+
}
|
|
259
|
+
if (Date.now() - start > HARD_TIMEOUT)
|
|
260
|
+
break;
|
|
261
|
+
if (lastChangeTime > 0 && Date.now() - lastChangeTime > SETTLE_MS)
|
|
262
|
+
break;
|
|
263
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
264
|
+
}
|
|
265
|
+
await unlockDone.catch(() => { });
|
|
266
|
+
return { dir, frames: frameIdx };
|
|
267
|
+
}
|
|
197
268
|
/**
|
|
198
269
|
* Restart the Next.js dev server via its internal endpoint, then reload.
|
|
199
270
|
* Polls /__nextjs_server_status until the executionId changes (new process).
|
|
@@ -348,11 +419,79 @@ export async function mcp(tool, args) {
|
|
|
348
419
|
export function network(idx) {
|
|
349
420
|
return idx == null ? net.format() : net.detail(idx);
|
|
350
421
|
}
|
|
422
|
+
// ── Viewport ─────────────────────────────────────────────────────────────────
|
|
423
|
+
/**
|
|
424
|
+
* Set the browser viewport to the given dimensions.
|
|
425
|
+
* Returns the applied size. Once set, the viewport stays fixed across
|
|
426
|
+
* navigations — use `viewport(null, null)` to restore auto-sizing.
|
|
427
|
+
*/
|
|
428
|
+
export async function viewport(width, height) {
|
|
429
|
+
if (!page)
|
|
430
|
+
throw new Error("browser not open");
|
|
431
|
+
if (width == null || height == null) {
|
|
432
|
+
// Reset to auto-sizing: match the browser window.
|
|
433
|
+
// Playwright doesn't expose a "reset viewport" API, so we read the
|
|
434
|
+
// current window bounds via CDP and set the viewport to match.
|
|
435
|
+
const cdp = await page.context().newCDPSession(page);
|
|
436
|
+
const { windowId } = await cdp.send("Browser.getWindowForTarget");
|
|
437
|
+
const { bounds } = await cdp.send("Browser.getWindowBounds", { windowId });
|
|
438
|
+
await cdp.detach();
|
|
439
|
+
// Account for browser chrome (~roughly 80px for title bar + tabs).
|
|
440
|
+
const w = bounds.width ?? 1280;
|
|
441
|
+
const h = (bounds.height ?? 800) - 80;
|
|
442
|
+
await page.setViewportSize({ width: w, height: h });
|
|
443
|
+
return { width: w, height: h };
|
|
444
|
+
}
|
|
445
|
+
await page.setViewportSize({ width, height });
|
|
446
|
+
// Also resize the physical window to match, so viewport == window.
|
|
447
|
+
try {
|
|
448
|
+
const cdp = await page.context().newCDPSession(page);
|
|
449
|
+
const { windowId } = await cdp.send("Browser.getWindowForTarget");
|
|
450
|
+
await cdp.send("Browser.setWindowBounds", {
|
|
451
|
+
windowId,
|
|
452
|
+
bounds: { width, height: height + 80 }, // +80 for browser chrome
|
|
453
|
+
});
|
|
454
|
+
await cdp.detach();
|
|
455
|
+
}
|
|
456
|
+
catch { }
|
|
457
|
+
return { width, height };
|
|
458
|
+
}
|
|
459
|
+
/** Get the current viewport dimensions. */
|
|
460
|
+
export async function viewportSize() {
|
|
461
|
+
if (!page)
|
|
462
|
+
throw new Error("browser not open");
|
|
463
|
+
const size = page.viewportSize();
|
|
464
|
+
if (size)
|
|
465
|
+
return size;
|
|
466
|
+
// viewport: null — read actual inner dimensions from the page.
|
|
467
|
+
return page.evaluate(() => ({
|
|
468
|
+
width: window.innerWidth,
|
|
469
|
+
height: window.innerHeight,
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
351
472
|
// ── Browser launch ───────────────────────────────────────────────────────────
|
|
473
|
+
/**
|
|
474
|
+
* Create a temporary Chrome profile directory with DevTools set to "undocked"
|
|
475
|
+
* so it opens in a separate window instead of docked inside the browser.
|
|
476
|
+
* This keeps the main browser viewport at full desktop size.
|
|
477
|
+
*/
|
|
478
|
+
function createProfileDir() {
|
|
479
|
+
const dir = join(tmpdir(), `next-browser-profile-${process.pid}`);
|
|
480
|
+
mkdirSync(join(dir, "Default"), { recursive: true });
|
|
481
|
+
writeFileSync(join(dir, "Default", "Preferences"), JSON.stringify({
|
|
482
|
+
devtools: {
|
|
483
|
+
preferences: {
|
|
484
|
+
currentDockState: '"undocked"',
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
}));
|
|
488
|
+
return dir;
|
|
489
|
+
}
|
|
352
490
|
/**
|
|
353
491
|
* Launch Chromium with React DevTools extension.
|
|
354
492
|
*
|
|
355
|
-
* - launchPersistentContext
|
|
493
|
+
* - launchPersistentContext with a pre-configured profile that sets DevTools
|
|
494
|
+
* to undocked mode — DevTools opens in a separate window, not docked
|
|
356
495
|
* - --load-extension loads the vendored React DevTools Chrome extension
|
|
357
496
|
* - --auto-open-devtools-for-tabs makes the extension activate its backend
|
|
358
497
|
* on every tab (same as a developer manually opening DevTools)
|
|
@@ -362,13 +501,16 @@ export function network(idx) {
|
|
|
362
501
|
* winning the race against the extension's content script
|
|
363
502
|
*/
|
|
364
503
|
async function launch() {
|
|
365
|
-
const
|
|
504
|
+
const profileDir = createProfileDir();
|
|
505
|
+
profileDirPath = profileDir;
|
|
506
|
+
const ctx = await chromium.launchPersistentContext(profileDir, {
|
|
366
507
|
headless: false,
|
|
367
|
-
viewport: null,
|
|
508
|
+
viewport: null, // let viewport follow the physical window size
|
|
368
509
|
args: [
|
|
369
510
|
`--disable-extensions-except=${extensionPath}`,
|
|
370
511
|
`--load-extension=${extensionPath}`,
|
|
371
512
|
"--auto-open-devtools-for-tabs",
|
|
513
|
+
"--window-size=1440,900",
|
|
372
514
|
],
|
|
373
515
|
});
|
|
374
516
|
await ctx.waitForEvent("serviceworker");
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,11 @@ if (cmd === "--help" || cmd === "-h" || !cmd) {
|
|
|
8
8
|
printUsage();
|
|
9
9
|
process.exit(0);
|
|
10
10
|
}
|
|
11
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
12
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
13
|
+
console.log(version);
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
11
16
|
if (cmd === "open") {
|
|
12
17
|
if (!arg) {
|
|
13
18
|
console.error("usage: next-browser open <url> [--cookies-json <file>]");
|
|
@@ -43,6 +48,15 @@ if (cmd === "reload") {
|
|
|
43
48
|
const res = await send("reload");
|
|
44
49
|
exit(res, res.ok ? `reloaded → ${res.data}` : "");
|
|
45
50
|
}
|
|
51
|
+
if (cmd === "capture-goto") {
|
|
52
|
+
const res = await send("capture-goto", arg ? { url: arg } : {});
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
exit(res, "");
|
|
55
|
+
const data = res.data;
|
|
56
|
+
exit(res, `${data.frames} frames → ${data.dir}\n` +
|
|
57
|
+
"\n" +
|
|
58
|
+
"frame-0000.png is the PPR shell. Remaining frames capture hydration → data.");
|
|
59
|
+
}
|
|
46
60
|
if (cmd === "restart-server") {
|
|
47
61
|
const res = await send("restart");
|
|
48
62
|
exit(res, res.ok ? `restarted → ${res.data}` : "");
|
|
@@ -119,6 +133,24 @@ if (cmd === "network") {
|
|
|
119
133
|
const res = await send("network", arg != null ? { idx: Number(arg) } : {});
|
|
120
134
|
exit(res, res.ok ? String(res.data) : "");
|
|
121
135
|
}
|
|
136
|
+
if (cmd === "viewport") {
|
|
137
|
+
if (arg) {
|
|
138
|
+
const match = arg.match(/^(\d+)x(\d+)$/);
|
|
139
|
+
if (!match) {
|
|
140
|
+
console.error("usage: next-browser viewport <width>x<height>");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const width = Number(match[1]);
|
|
144
|
+
const height = Number(match[2]);
|
|
145
|
+
const res = await send("viewport", { width, height });
|
|
146
|
+
exit(res, res.ok ? `viewport set to ${width}x${height}` : "");
|
|
147
|
+
}
|
|
148
|
+
const res = await send("viewport", {});
|
|
149
|
+
if (!res.ok)
|
|
150
|
+
exit(res, "");
|
|
151
|
+
const data = res.data;
|
|
152
|
+
exit(res, `${data.width}x${data.height}`);
|
|
153
|
+
}
|
|
122
154
|
if (cmd === "close") {
|
|
123
155
|
const res = await send("close");
|
|
124
156
|
exit(res, "closed");
|
|
@@ -197,6 +229,7 @@ function printUsage() {
|
|
|
197
229
|
" push [path] client-side navigation (interactive picker if no path)\n" +
|
|
198
230
|
" back go back in history\n" +
|
|
199
231
|
" reload reload current page\n" +
|
|
232
|
+
" capture-goto [url] capture loading sequence (PPR shell → hydration → data)\n" +
|
|
200
233
|
" restart-server restart the Next.js dev server (clears fs cache)\n" +
|
|
201
234
|
"\n" +
|
|
202
235
|
" ppr lock enter PPR instant-navigation mode\n" +
|
|
@@ -205,6 +238,7 @@ function printUsage() {
|
|
|
205
238
|
" tree show React component tree\n" +
|
|
206
239
|
" tree <id> inspect component (props, hooks, state, source)\n" +
|
|
207
240
|
"\n" +
|
|
241
|
+
" viewport [WxH] show or set viewport size (e.g. 1280x720)\n" +
|
|
208
242
|
" screenshot save full-page screenshot to tmp file\n" +
|
|
209
243
|
" eval <script> evaluate JS in page context\n" +
|
|
210
244
|
"\n" +
|
package/dist/daemon.js
CHANGED
|
@@ -57,6 +57,10 @@ async function run(cmd) {
|
|
|
57
57
|
const data = await browser.reload();
|
|
58
58
|
return { ok: true, data };
|
|
59
59
|
}
|
|
60
|
+
if (cmd.action === "capture-goto") {
|
|
61
|
+
const data = await browser.captureGoto(cmd.url);
|
|
62
|
+
return { ok: true, data };
|
|
63
|
+
}
|
|
60
64
|
if (cmd.action === "restart") {
|
|
61
65
|
const data = await browser.restart();
|
|
62
66
|
return { ok: true, data };
|
|
@@ -101,6 +105,14 @@ async function run(cmd) {
|
|
|
101
105
|
const data = await browser.network(cmd.idx);
|
|
102
106
|
return { ok: true, data };
|
|
103
107
|
}
|
|
108
|
+
if (cmd.action === "viewport") {
|
|
109
|
+
if (cmd.width !== undefined) {
|
|
110
|
+
const data = await browser.viewport(cmd.width, cmd.height);
|
|
111
|
+
return { ok: true, data };
|
|
112
|
+
}
|
|
113
|
+
const data = await browser.viewportSize();
|
|
114
|
+
return { ok: true, data };
|
|
115
|
+
}
|
|
104
116
|
if (cmd.action === "close") {
|
|
105
117
|
await browser.close();
|
|
106
118
|
return { ok: true };
|