@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 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
- ## Install
13
+ ## Getting started
14
14
 
15
- ```bash
16
- pnpm add -g @vercel/next-browser
17
- ```
15
+ You don't install or run this directly. Your agent does.
18
16
 
19
- Requires Node `>=20`.
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
- ## Usage
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 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
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("") empty user data dir (fresh profile each time)
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 ctx = await chromium.launchPersistentContext("", {
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/next-browser",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Headed Playwright browser with React DevTools pre-loaded",
5
5
  "license": "MIT",
6
6
  "repository": {