@vercel/next-browser 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -38,7 +38,7 @@ Requires Node >= 20.
38
38
 
39
39
  | Command | Description |
40
40
  | ------------------------------------ | -------------------------------------------------- |
41
- | `open <url> [--cookies-json <file>]` | Launch browser and navigate (with optional cookies) |
41
+ | `open <url> [--cookies <file>]` | Launch browser and navigate (with optional cookies) |
42
42
  | `close` | Close browser and kill daemon |
43
43
 
44
44
  ### Navigation
@@ -62,9 +62,9 @@ Requires Node >= 20.
62
62
  | `snapshot` | Accessibility tree with `[ref=eN]` markers on interactive elements |
63
63
  | `errors` | Build and runtime errors for the current page |
64
64
  | `logs` | Recent dev server log output |
65
+ | `browser-logs` | Browser console output (log, warn, error, info) |
65
66
  | `network [idx]` | List network requests, or inspect one (headers, body) |
66
- | `preview [caption]` | Screenshot + open in viewer window (accumulates across calls) |
67
- | `screenshot` | Viewport PNG to a temp file (`--full-page` for entire page) |
67
+ | `screenshot [caption] [--full-page]` | Viewport PNG to a temp file (caption shown in Screenshot Log) |
68
68
 
69
69
  ### Interaction
70
70
 
@@ -77,11 +77,20 @@ Requires Node >= 20.
77
77
 
78
78
  ### Performance & PPR
79
79
 
80
- | Command | Description |
81
- | -------------- | ------------------------------------------------------------ |
82
- | `perf [url]` | Core Web Vitals + React hydration timing in one pass |
83
- | `ppr lock` | Freeze dynamic content to inspect the static shell |
84
- | `ppr unlock` | Resume dynamic content and print shell analysis |
80
+ | Command | Description |
81
+ | ------------------------------ | ---------------------------------------------------- |
82
+ | `perf [url]` | Core Web Vitals + React hydration timing in one pass |
83
+ | `renders start` | Start recording React re-renders |
84
+ | `renders stop [--json]` | Stop and print per-component render profile |
85
+ | `ppr lock` | Freeze dynamic content to inspect the static shell |
86
+ | `ppr unlock` | Resume dynamic content and print shell analysis |
87
+
88
+ ### Instrumentation
89
+
90
+ | Command | Description |
91
+ | ------------------------------ | ---------------------------------------------------- |
92
+ | `instrumentation set <path>` | Inject script before page scripts on every navigation |
93
+ | `instrumentation clear` | Remove instrumentation script |
85
94
 
86
95
  ### Next.js MCP
87
96
 
package/dist/browser.js CHANGED
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Module-level state: one browser context, one page, one PPR lock.
12
12
  */
13
- import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
13
+ import { readFileSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
14
14
  import { join, resolve } from "node:path";
15
15
  import { tmpdir } from "node:os";
16
16
  import { chromium } from "playwright";
@@ -30,6 +30,7 @@ let page = null;
30
30
  let profileDirPath = null;
31
31
  let initialOrigin = null;
32
32
  let ssrLocked = false;
33
+ let instrumentationVersion = 0;
33
34
  let screenshotBrowser = null;
34
35
  let screenshotPage = null;
35
36
  let screenshotEntries = [];
@@ -87,6 +88,7 @@ export async function close() {
87
88
  release = null;
88
89
  settled = null;
89
90
  ssrLocked = false;
91
+ instrumentationVersion = 0;
90
92
  // Clean up temp profile directory.
91
93
  if (profileDirPath) {
92
94
  const { rmSync } = await import("node:fs");
@@ -95,6 +97,30 @@ export async function close() {
95
97
  initialOrigin = null;
96
98
  }
97
99
  }
100
+ // ── Instrumentation ─────────────────────────────────────────────────────────
101
+ //
102
+ // addInitScript can't be removed, so we version-gate: each `set` bumps the
103
+ // version and registers a new script that only runs when its version matches.
104
+ // `clear` bumps the version with no matching script, disabling the old one.
105
+ export async function instrumentationSet(script) {
106
+ if (!page)
107
+ throw new Error("browser not open");
108
+ const ctx = page.context();
109
+ const v = ++instrumentationVersion;
110
+ // The version gate runs first, then the guarded script.
111
+ await ctx.addInitScript(`window.__NB_IV__=${v}`);
112
+ await ctx.addInitScript(`if(window.__NB_IV__===${v}){${script}}`);
113
+ // Apply immediately on the current page.
114
+ await page.evaluate(script);
115
+ }
116
+ export async function instrumentationClear() {
117
+ if (!page)
118
+ throw new Error("browser not open");
119
+ const ctx = page.context();
120
+ // Bump version — no matching script, so nothing runs.
121
+ const v = ++instrumentationVersion;
122
+ await ctx.addInitScript(`window.__NB_IV__=${v}`);
123
+ }
98
124
  // ── PPR lock/unlock ──────────────────────────────────────────────────────────
99
125
  //
100
126
  // The lock uses @next/playwright's `instant()` which sets the
@@ -568,21 +594,99 @@ async function refreshScreenshotLog() {
568
594
  function escapeHtml(s) {
569
595
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
570
596
  }
597
+ /** Max screenshot dimension in pixels — keeps images under the 2000px limit
598
+ * that multi-image LLM requests impose. */
599
+ const SCREENSHOT_MAX_DIM = 1280;
571
600
  /** Screenshot saved to a temp file. Opens the Screenshot Log window in headed mode.
572
- * Returns the file path. */
601
+ * Returns the file path for a single image, or a directory path when the
602
+ * image is sliced into multiple chunks (full-page on long pages). */
573
603
  export async function screenshot(opts) {
574
604
  if (!page)
575
605
  throw new Error("browser not open");
576
606
  await hideDevOverlay();
577
- const { join } = await import("node:path");
578
- const { tmpdir } = await import("node:os");
579
607
  const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
580
- await page.screenshot({ path, fullPage: opts?.fullPage });
581
- const imgData = readFileSync(path).toString("base64");
608
+ // scale: 'css' prevents Retina 2x doubling (1440x900 stays 1440x900).
609
+ await page.screenshot({ path, fullPage: opts?.fullPage, scale: "css" });
610
+ const result = await sliceIfNeeded(path, SCREENSHOT_MAX_DIM);
582
611
  const timestamp = new Date().toLocaleTimeString();
583
- screenshotEntries.unshift({ caption: opts?.caption, imgData, timestamp });
612
+ if (typeof result === "string") {
613
+ // Single file — fits within limits.
614
+ const imgData = readFileSync(result).toString("base64");
615
+ screenshotEntries.unshift({ caption: opts?.caption, imgData, timestamp });
616
+ }
617
+ else {
618
+ // Multiple chunks — add each to the screenshot log.
619
+ for (const chunk of result.files) {
620
+ const imgData = readFileSync(chunk).toString("base64");
621
+ const label = opts?.caption
622
+ ? `${opts.caption} (${result.files.indexOf(chunk) + 1}/${result.files.length})`
623
+ : `chunk ${result.files.indexOf(chunk) + 1}/${result.files.length}`;
624
+ screenshotEntries.unshift({ caption: label, imgData, timestamp });
625
+ }
626
+ }
584
627
  await refreshScreenshotLog();
585
- return path;
628
+ return typeof result === "string" ? result : result.dir;
629
+ }
630
+ /** If the screenshot fits within `maxDim`, scale it in-place and return the
631
+ * path. Otherwise slice it into ≤maxDim chunks inside a temp directory and
632
+ * return `{ dir, files }`. */
633
+ async function sliceIfNeeded(filePath, maxDim) {
634
+ const buf = readFileSync(filePath);
635
+ if (buf.length < 24)
636
+ return filePath;
637
+ const w = buf.readUInt32BE(16);
638
+ const h = buf.readUInt32BE(20);
639
+ if (w <= maxDim && h <= maxDim)
640
+ return filePath;
641
+ if (!page)
642
+ return filePath;
643
+ const b64 = buf.toString("base64");
644
+ const wScale = Math.min(maxDim / w, 1);
645
+ const scaledW = Math.round(w * wScale);
646
+ const scaledH = Math.round(h * wScale);
647
+ if (scaledH <= maxDim) {
648
+ // Fits after width scaling — single resized file.
649
+ const resized = await page.evaluate(async ({ src, nw, nh }) => {
650
+ const img = new Image();
651
+ await new Promise((r, e) => { img.onload = () => r(); img.onerror = () => e(); img.src = `data:image/png;base64,${src}`; });
652
+ const c = document.createElement("canvas");
653
+ c.width = nw;
654
+ c.height = nh;
655
+ c.getContext("2d").drawImage(img, 0, 0, nw, nh);
656
+ return c.toDataURL("image/png").split(",")[1];
657
+ }, { src: b64, nw: scaledW, nh: scaledH });
658
+ writeFileSync(filePath, Buffer.from(resized, "base64"));
659
+ return filePath;
660
+ }
661
+ // Slice into chunks of maxDim height (in scaled pixels).
662
+ const chunkCount = Math.ceil(scaledH / maxDim);
663
+ const chunks = await page.evaluate(async ({ src, nw, totalH, max, count }) => {
664
+ const img = new Image();
665
+ await new Promise((r, e) => { img.onload = () => r(); img.onerror = () => e(); img.src = `data:image/png;base64,${src}`; });
666
+ const results = [];
667
+ for (let i = 0; i < count; i++) {
668
+ const sy = (i * max) / (totalH / img.height);
669
+ const sh = Math.min(max, totalH - i * max) / (totalH / img.height);
670
+ const dh = Math.min(max, totalH - i * max);
671
+ const c = document.createElement("canvas");
672
+ c.width = nw;
673
+ c.height = dh;
674
+ c.getContext("2d").drawImage(img, 0, sy, img.width, sh, 0, 0, nw, dh);
675
+ results.push(c.toDataURL("image/png").split(",")[1]);
676
+ }
677
+ return results;
678
+ }, { src: b64, nw: scaledW, totalH: scaledH, max: maxDim, count: chunkCount });
679
+ const dir = join(tmpdir(), `next-browser-screenshots-${Date.now()}`);
680
+ mkdirSync(dir, { recursive: true });
681
+ const files = [];
682
+ for (let i = 0; i < chunks.length; i++) {
683
+ const p = join(dir, `${String(i + 1).padStart(3, "0")}.png`);
684
+ writeFileSync(p, Buffer.from(chunks[i], "base64"));
685
+ files.push(p);
686
+ }
687
+ // Clean up the original full file.
688
+ unlinkSync(filePath);
689
+ return { dir, files };
586
690
  }
587
691
  /** Remove Next.js devtools overlay from the page before screenshots. */
588
692
  async function hideDevOverlay() {
package/dist/cli.js CHANGED
@@ -369,6 +369,20 @@ if (cmd === "viewport") {
369
369
  const data = res.data;
370
370
  exit(res, `${data.width}x${data.height}`);
371
371
  }
372
+ if (cmd === "instrumentation" && arg === "set") {
373
+ const filePath = args[2];
374
+ if (!filePath) {
375
+ console.error("usage: next-browser instrumentation set <path>");
376
+ process.exit(1);
377
+ }
378
+ const script = readFileSync(filePath, "utf-8");
379
+ const res = await send("instrumentation-set", { instrumentationScript: script });
380
+ exit(res, "instrumentation set");
381
+ }
382
+ if (cmd === "instrumentation" && arg === "clear") {
383
+ const res = await send("instrumentation-clear");
384
+ exit(res, "instrumentation cleared");
385
+ }
372
386
  if (cmd === "close") {
373
387
  const res = await send("close");
374
388
  exit(res, "closed");
@@ -477,5 +491,8 @@ function printUsage() {
477
491
  " page show current page segments and router info\n" +
478
492
  " project show project path and dev server url\n" +
479
493
  " routes list app routes\n" +
480
- " action <id> inspect a server action by id");
494
+ " action <id> inspect a server action by id\n" +
495
+ "\n" +
496
+ " instrumentation set <path> inject script before page scripts\n" +
497
+ " instrumentation clear remove instrumentation script");
481
498
  }
package/dist/daemon.js CHANGED
@@ -145,6 +145,14 @@ async function run(cmd) {
145
145
  const data = await browser.viewportSize();
146
146
  return { ok: true, data };
147
147
  }
148
+ if (cmd.action === "instrumentation-set") {
149
+ await browser.instrumentationSet(cmd.instrumentationScript);
150
+ return { ok: true };
151
+ }
152
+ if (cmd.action === "instrumentation-clear") {
153
+ await browser.instrumentationClear();
154
+ return { ok: true };
155
+ }
148
156
  if (cmd.action === "close") {
149
157
  await browser.close();
150
158
  return { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/next-browser",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Headed Playwright browser with React DevTools pre-loaded",
5
5
  "license": "MIT",
6
6
  "repository": {