@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 +6 -4
- package/dist/browser.js +114 -31
- package/dist/cli.js +19 -6
- package/dist/daemon.js +12 -4
- package/package.json +1 -1
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
|
|
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
|
-
| `
|
|
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
|
|
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 (
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
496
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
124
|
-
const res = await send("ssr-
|
|
125
|
-
exit(res,
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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-
|
|
85
|
-
|
|
86
|
-
return { ok: true
|
|
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();
|