@vercel/next-browser 0.2.0 → 0.3.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
@@ -46,7 +46,8 @@ Requires Node >= 20.
46
46
  | Command | Description |
47
47
  | ------------------ | --------------------------------------------------------- |
48
48
  | `goto <url>` | Full-page navigation (new document load) |
49
- | `ssr-goto <url>` | Navigate blocking external scripts (inspect SSR shell) |
49
+ | `ssr lock` | Block external scripts on all navigations (SSR-only mode) |
50
+ | `ssr unlock` | Re-enable external scripts |
50
51
  | `push [path]` | Client-side navigation (interactive picker if no path) |
51
52
  | `back` | Go back in history |
52
53
  | `reload` | Reload current page |
@@ -62,7 +63,8 @@ Requires Node >= 20.
62
63
  | `errors` | Build and runtime errors for the current page |
63
64
  | `logs` | Recent dev server log output |
64
65
  | `network [idx]` | List network requests, or inspect one (headers, body) |
65
- | `screenshot` | Full-page PNG to a temp file |
66
+ | `preview [caption]` | Screenshot + open in viewer window (accumulates across calls) |
67
+ | `screenshot` | Viewport PNG to a temp file (`--full-page` for entire page) |
66
68
 
67
69
  ### Interaction
68
70
 
@@ -138,8 +140,8 @@ $ next-browser perf http://localhost:3000/dashboard
138
140
  $ next-browser ppr lock
139
141
  locked
140
142
  $ next-browser goto http://localhost:3000/dashboard
141
- $ next-browser screenshot
142
- /var/folders/.../next-browser-screenshot.png
143
+ $ next-browser preview "PPR shell — locked"
144
+ preview → /var/folders/.../next-browser-screenshot.png
143
145
  $ next-browser ppr unlock
144
146
  # PPR Shell Analysis — 131 boundaries: 3 dynamic holes, 128 static
145
147
 
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 } from "node:fs";
13
+ import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
14
14
  import { join, resolve } from "node:path";
15
15
  import { tmpdir } from "node:os";
16
16
  import { chromium } from "playwright";
@@ -29,6 +29,23 @@ let context = null;
29
29
  let page = null;
30
30
  let profileDirPath = null;
31
31
  let initialOrigin = null;
32
+ let ssrLocked = false;
33
+ let previewBrowser = null;
34
+ let previewPage = null;
35
+ let previewImages = [];
36
+ /** Install or remove the script-blocking route handler based on ssrLocked. */
37
+ async function syncSsrRoutes() {
38
+ if (!page)
39
+ return;
40
+ await page.unrouteAll({ behavior: "wait" });
41
+ if (ssrLocked) {
42
+ await page.route("**/*", (route) => {
43
+ if (route.request().resourceType() === "script")
44
+ return route.abort();
45
+ return route.continue();
46
+ });
47
+ }
48
+ }
32
49
  // ── Browser lifecycle ────────────────────────────────────────────────────────
33
50
  /**
34
51
  * Launch the browser (if not already open) and optionally navigate to a URL.
@@ -36,11 +53,12 @@ let initialOrigin = null;
36
53
  * reuse the existing context.
37
54
  */
38
55
  export async function open(url) {
39
- if (!context) {
40
- context = await launch();
41
- page = context.pages()[0] ?? (await context.newPage());
42
- net.attach(page);
56
+ if (context) {
57
+ await close();
43
58
  }
59
+ context = await launch();
60
+ page = context.pages()[0] ?? (await context.newPage());
61
+ net.attach(page);
44
62
  if (url) {
45
63
  initialOrigin = new URL(url).origin;
46
64
  await page.goto(url, { waitUntil: "domcontentloaded" });
@@ -59,11 +77,16 @@ export async function cookies(cookies, domain) {
59
77
  }
60
78
  /** Close the browser and reset all state. */
61
79
  export async function close() {
80
+ await previewBrowser?.close().catch(() => { });
81
+ previewBrowser = null;
82
+ previewPage = null;
83
+ previewImages = [];
62
84
  await context?.close();
63
85
  context = null;
64
86
  page = null;
65
87
  release = null;
66
88
  settled = null;
89
+ ssrLocked = false;
67
90
  // Clean up temp profile directory.
68
91
  if (profileDirPath) {
69
92
  const { rmSync } = await import("node:fs");
@@ -88,7 +111,7 @@ export function lock() {
88
111
  if (!page)
89
112
  throw new Error("browser not open");
90
113
  if (release)
91
- throw new Error("already locked");
114
+ return Promise.resolve();
92
115
  return new Promise((locked) => {
93
116
  settled = instant(page, () => {
94
117
  locked();
@@ -225,6 +248,29 @@ async function waitForDevToolsReconnect(p) {
225
248
  await new Promise((r) => setTimeout(r, 200));
226
249
  }
227
250
  }
251
+ // ── SSR lock/unlock ──────────────────────────────────────────────────────────
252
+ //
253
+ // While SSR-locked, every navigation blocks external script resources so the
254
+ // page renders only the server-side HTML shell (no React hydration, no client
255
+ // bundles). Useful for inspecting raw SSR output across multiple navigations.
256
+ /** Enter SSR-locked mode. All subsequent navigations block external scripts. */
257
+ export async function ssrLock() {
258
+ if (!page)
259
+ throw new Error("browser not open");
260
+ if (ssrLocked)
261
+ return;
262
+ ssrLocked = true;
263
+ await syncSsrRoutes();
264
+ }
265
+ /** Exit SSR-locked mode. Re-enables external scripts. */
266
+ export async function ssrUnlock() {
267
+ if (!page)
268
+ throw new Error("browser not open");
269
+ if (!ssrLocked)
270
+ return;
271
+ ssrLocked = false;
272
+ await syncSsrRoutes();
273
+ }
228
274
  // ── Navigation ───────────────────────────────────────────────────────────────
229
275
  /** Hard reload the current page. Returns the URL after reload. */
230
276
  export async function reload() {
@@ -412,30 +458,9 @@ export async function push(path) {
412
458
  /** Full-page navigation (new document load). Resolves relative URLs against the current page. */
413
459
  export async function goto(url) {
414
460
  if (!page)
415
- throw new Error("browser not open");
416
- await page.unrouteAll({ behavior: "wait" });
417
- const target = new URL(url, page.url()).href;
418
- initialOrigin = new URL(target).origin;
419
- await page.goto(target, { waitUntil: "domcontentloaded" });
420
- return target;
421
- }
422
- /**
423
- * Navigate like goto but block external script resources.
424
- * The HTML loads and inline <script> blocks still execute, but external JS
425
- * bundles (React, hydration, etc.) are aborted. Shows the SSR shell.
426
- */
427
- export async function ssrGoto(url) {
428
- if (!page)
429
- throw new Error("browser not open");
461
+ await open(undefined);
430
462
  const target = new URL(url, page.url()).href;
431
463
  initialOrigin = new URL(target).origin;
432
- // Clear any stale route handlers from previous ssr-goto calls.
433
- await page.unrouteAll({ behavior: "wait" });
434
- await page.route("**/*", (route) => {
435
- if (route.request().resourceType() === "script")
436
- return route.abort();
437
- return route.continue();
438
- });
439
464
  await page.goto(target, { waitUntil: "domcontentloaded" });
440
465
  return target;
441
466
  }
@@ -492,15 +517,73 @@ async function formatSource([file, line, col]) {
492
517
  return `source: ${file}:${line}:${col}`;
493
518
  }
494
519
  // ── Utilities ────────────────────────────────────────────────────────────────
495
- /** Viewport screenshot saved to a temp file. Returns the file path. */
496
- export async function screenshot() {
520
+ /** Take a screenshot and display it in a separate headed Chromium window.
521
+ * Images accumulate across calls — use `clear` to reset. */
522
+ export async function preview(caption, clear) {
523
+ if (!page)
524
+ throw new Error("browser not open");
525
+ if (clear)
526
+ previewImages = [];
527
+ const path = await screenshot();
528
+ const imgData = readFileSync(path).toString("base64");
529
+ const timestamp = new Date().toLocaleTimeString();
530
+ previewImages.unshift({ caption, imgData, timestamp });
531
+ const imagesHtml = previewImages
532
+ .map((img) => `<div style="padding:4px 12px;display:flex;align-items:baseline;gap:8px">` +
533
+ (img.caption
534
+ ? `<span style="font-size:14px">${escapeHtml(img.caption)}</span>`
535
+ : "") +
536
+ `<span style="font-size:11px;opacity:0.5">${escapeHtml(img.timestamp)}</span>` +
537
+ `</div>` +
538
+ `<img src="data:image/png;base64,${img.imgData}" style="display:block;max-width:100%">`)
539
+ .join(`<hr style="border:none;border-top:1px solid #333;margin:12px 0">`);
540
+ const html = `<html><head><title>next-browser preview</title></head>` +
541
+ `<body style="margin:0;background:#111;color:#fff;font-family:system-ui">` +
542
+ `<div style="padding:8px 12px;font-size:11px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">next-browser preview</div>` +
543
+ `${imagesHtml}` +
544
+ `</body></html>`;
545
+ const htmlPath = path.replace(/\.png$/, ".html");
546
+ writeFileSync(htmlPath, html);
547
+ const target = `file://${htmlPath}`;
548
+ // Reuse existing preview window, or launch a new one.
549
+ if (previewPage && !previewPage.isClosed()) {
550
+ try {
551
+ await previewPage.goto(target);
552
+ await previewPage.bringToFront();
553
+ return path;
554
+ }
555
+ catch {
556
+ // Window was closed by user — fall through to launch a new one.
557
+ await previewBrowser?.close().catch(() => { });
558
+ }
559
+ }
560
+ const { mkdtempSync } = await import("node:fs");
561
+ const { join } = await import("node:path");
562
+ const { tmpdir } = await import("node:os");
563
+ const userDataDir = mkdtempSync(join(tmpdir(), "nb-preview-"));
564
+ const ctx = await chromium.launchPersistentContext(userDataDir, {
565
+ headless: false,
566
+ args: [`--app=${target}`, "--window-size=820,640"],
567
+ viewport: null,
568
+ });
569
+ previewBrowser = ctx;
570
+ previewPage = ctx.pages()[0] ?? (await ctx.waitForEvent("page"));
571
+ await previewPage.waitForLoadState();
572
+ await previewPage.bringToFront();
573
+ return path;
574
+ }
575
+ function escapeHtml(s) {
576
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
577
+ }
578
+ /** Screenshot saved to a temp file. Returns the file path. */
579
+ export async function screenshot(opts) {
497
580
  if (!page)
498
581
  throw new Error("browser not open");
499
582
  await hideDevOverlay();
500
583
  const { join } = await import("node:path");
501
584
  const { tmpdir } = await import("node:os");
502
585
  const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
503
- await page.screenshot({ path });
586
+ await page.screenshot({ path, fullPage: opts?.fullPage });
504
587
  return path;
505
588
  }
506
589
  /** Remove Next.js devtools overlay from the page before screenshots. */
package/dist/cli.js CHANGED
@@ -120,16 +120,27 @@ if (cmd === "goto") {
120
120
  const res = await send("goto", { url: arg });
121
121
  exit(res, res.ok ? `→ ${res.data}` : "");
122
122
  }
123
- if (cmd === "ssr-goto") {
124
- const res = await send("ssr-goto", { url: arg });
125
- exit(res, res.ok ? `→ ${res.data} (external scripts blocked)` : "");
123
+ if (cmd === "ssr" && arg === "lock") {
124
+ const res = await send("ssr-lock");
125
+ exit(res, "ssr locked external scripts blocked on all navigations");
126
+ }
127
+ if (cmd === "ssr" && arg === "unlock") {
128
+ const res = await send("ssr-unlock");
129
+ exit(res, "ssr unlocked — external scripts re-enabled");
126
130
  }
127
131
  if (cmd === "back") {
128
132
  const res = await send("back");
129
133
  exit(res, "back");
130
134
  }
135
+ if (cmd === "preview") {
136
+ const clear = args.includes("--clear");
137
+ const caption = args.filter((a) => a !== "--clear").slice(1).join(" ") || undefined;
138
+ const res = await send("preview", { caption, clear });
139
+ exit(res, res.ok ? `preview → ${res.data}` : "");
140
+ }
131
141
  if (cmd === "screenshot") {
132
- const res = await send("screenshot");
142
+ const fullPage = args.includes("--full-page");
143
+ const res = await send("screenshot", { fullPage });
133
144
  exit(res, res.ok ? String(res.data) : "");
134
145
  }
135
146
  if (cmd === "snapshot") {
@@ -317,7 +328,8 @@ function printUsage() {
317
328
  " close close browser and daemon\n" +
318
329
  "\n" +
319
330
  " goto <url> full-page navigation (new document load)\n" +
320
- " ssr-goto <url> goto but block external scripts (SSR shell)\n" +
331
+ " ssr lock block external scripts on all navigations\n" +
332
+ " ssr unlock re-enable external scripts\n" +
321
333
  " push [path] client-side navigation (interactive picker if no path)\n" +
322
334
  " back go back in history\n" +
323
335
  " reload reload current page\n" +
@@ -331,7 +343,8 @@ function printUsage() {
331
343
  " tree <id> inspect component (props, hooks, state, source)\n" +
332
344
  "\n" +
333
345
  " viewport [WxH] show or set viewport size (e.g. 1280x720)\n" +
334
- " screenshot save full-page screenshot to tmp file\n" +
346
+ " preview [caption] [--clear] screenshot + open in viewer (accumulates)\n" +
347
+ " screenshot [--full-page] save screenshot to tmp file\n" +
335
348
  " snapshot accessibility tree with interactive refs\n" +
336
349
  " click <ref|sel> click an element (real pointer events)\n" +
337
350
  " fill <ref|sel> <v> fill a text input\n" +
package/dist/daemon.js CHANGED
@@ -65,8 +65,12 @@ async function run(cmd) {
65
65
  const data = await browser.restart();
66
66
  return { ok: true, data };
67
67
  }
68
+ if (cmd.action === "preview") {
69
+ const data = await browser.preview(cmd.caption, cmd.clear);
70
+ return { ok: true, data };
71
+ }
68
72
  if (cmd.action === "screenshot") {
69
- const data = await browser.screenshot();
73
+ const data = await browser.screenshot({ fullPage: cmd.fullPage });
70
74
  return { ok: true, data };
71
75
  }
72
76
  if (cmd.action === "links") {
@@ -81,9 +85,13 @@ async function run(cmd) {
81
85
  const data = await browser.goto(cmd.url);
82
86
  return { ok: true, data };
83
87
  }
84
- if (cmd.action === "ssr-goto") {
85
- const data = await browser.ssrGoto(cmd.url);
86
- return { ok: true, data };
88
+ if (cmd.action === "ssr-lock") {
89
+ await browser.ssrLock();
90
+ return { ok: true };
91
+ }
92
+ if (cmd.action === "ssr-unlock") {
93
+ await browser.ssrUnlock();
94
+ return { ok: true };
87
95
  }
88
96
  if (cmd.action === "back") {
89
97
  await browser.back();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/next-browser",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Headed Playwright browser with React DevTools pre-loaded",
5
5
  "license": "MIT",
6
6
  "repository": {