@vercel/next-browser 0.4.1 → 0.5.1
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/dist/browser.js +86 -8
- package/dist/cli.js +17 -5
- package/dist/cookies.js +63 -0
- package/package.json +1 -1
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";
|
|
@@ -568,21 +568,99 @@ async function refreshScreenshotLog() {
|
|
|
568
568
|
function escapeHtml(s) {
|
|
569
569
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
570
570
|
}
|
|
571
|
+
/** Max screenshot dimension in pixels — keeps images under the 2000px limit
|
|
572
|
+
* that multi-image LLM requests impose. */
|
|
573
|
+
const SCREENSHOT_MAX_DIM = 1280;
|
|
571
574
|
/** Screenshot saved to a temp file. Opens the Screenshot Log window in headed mode.
|
|
572
|
-
* Returns the file path
|
|
575
|
+
* Returns the file path for a single image, or a directory path when the
|
|
576
|
+
* image is sliced into multiple chunks (full-page on long pages). */
|
|
573
577
|
export async function screenshot(opts) {
|
|
574
578
|
if (!page)
|
|
575
579
|
throw new Error("browser not open");
|
|
576
580
|
await hideDevOverlay();
|
|
577
|
-
const { join } = await import("node:path");
|
|
578
|
-
const { tmpdir } = await import("node:os");
|
|
579
581
|
const path = join(tmpdir(), `next-browser-${Date.now()}.png`);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
+
// scale: 'css' prevents Retina 2x doubling (1440x900 stays 1440x900).
|
|
583
|
+
await page.screenshot({ path, fullPage: opts?.fullPage, scale: "css" });
|
|
584
|
+
const result = await sliceIfNeeded(path, SCREENSHOT_MAX_DIM);
|
|
582
585
|
const timestamp = new Date().toLocaleTimeString();
|
|
583
|
-
|
|
586
|
+
if (typeof result === "string") {
|
|
587
|
+
// Single file — fits within limits.
|
|
588
|
+
const imgData = readFileSync(result).toString("base64");
|
|
589
|
+
screenshotEntries.unshift({ caption: opts?.caption, imgData, timestamp });
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
// Multiple chunks — add each to the screenshot log.
|
|
593
|
+
for (const chunk of result.files) {
|
|
594
|
+
const imgData = readFileSync(chunk).toString("base64");
|
|
595
|
+
const label = opts?.caption
|
|
596
|
+
? `${opts.caption} (${result.files.indexOf(chunk) + 1}/${result.files.length})`
|
|
597
|
+
: `chunk ${result.files.indexOf(chunk) + 1}/${result.files.length}`;
|
|
598
|
+
screenshotEntries.unshift({ caption: label, imgData, timestamp });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
584
601
|
await refreshScreenshotLog();
|
|
585
|
-
return
|
|
602
|
+
return typeof result === "string" ? result : result.dir;
|
|
603
|
+
}
|
|
604
|
+
/** If the screenshot fits within `maxDim`, scale it in-place and return the
|
|
605
|
+
* path. Otherwise slice it into ≤maxDim chunks inside a temp directory and
|
|
606
|
+
* return `{ dir, files }`. */
|
|
607
|
+
async function sliceIfNeeded(filePath, maxDim) {
|
|
608
|
+
const buf = readFileSync(filePath);
|
|
609
|
+
if (buf.length < 24)
|
|
610
|
+
return filePath;
|
|
611
|
+
const w = buf.readUInt32BE(16);
|
|
612
|
+
const h = buf.readUInt32BE(20);
|
|
613
|
+
if (w <= maxDim && h <= maxDim)
|
|
614
|
+
return filePath;
|
|
615
|
+
if (!page)
|
|
616
|
+
return filePath;
|
|
617
|
+
const b64 = buf.toString("base64");
|
|
618
|
+
const wScale = Math.min(maxDim / w, 1);
|
|
619
|
+
const scaledW = Math.round(w * wScale);
|
|
620
|
+
const scaledH = Math.round(h * wScale);
|
|
621
|
+
if (scaledH <= maxDim) {
|
|
622
|
+
// Fits after width scaling — single resized file.
|
|
623
|
+
const resized = await page.evaluate(async ({ src, nw, nh }) => {
|
|
624
|
+
const img = new Image();
|
|
625
|
+
await new Promise((r, e) => { img.onload = () => r(); img.onerror = () => e(); img.src = `data:image/png;base64,${src}`; });
|
|
626
|
+
const c = document.createElement("canvas");
|
|
627
|
+
c.width = nw;
|
|
628
|
+
c.height = nh;
|
|
629
|
+
c.getContext("2d").drawImage(img, 0, 0, nw, nh);
|
|
630
|
+
return c.toDataURL("image/png").split(",")[1];
|
|
631
|
+
}, { src: b64, nw: scaledW, nh: scaledH });
|
|
632
|
+
writeFileSync(filePath, Buffer.from(resized, "base64"));
|
|
633
|
+
return filePath;
|
|
634
|
+
}
|
|
635
|
+
// Slice into chunks of maxDim height (in scaled pixels).
|
|
636
|
+
const chunkCount = Math.ceil(scaledH / maxDim);
|
|
637
|
+
const chunks = await page.evaluate(async ({ src, nw, totalH, max, count }) => {
|
|
638
|
+
const img = new Image();
|
|
639
|
+
await new Promise((r, e) => { img.onload = () => r(); img.onerror = () => e(); img.src = `data:image/png;base64,${src}`; });
|
|
640
|
+
const results = [];
|
|
641
|
+
for (let i = 0; i < count; i++) {
|
|
642
|
+
const sy = (i * max) / (totalH / img.height);
|
|
643
|
+
const sh = Math.min(max, totalH - i * max) / (totalH / img.height);
|
|
644
|
+
const dh = Math.min(max, totalH - i * max);
|
|
645
|
+
const c = document.createElement("canvas");
|
|
646
|
+
c.width = nw;
|
|
647
|
+
c.height = dh;
|
|
648
|
+
c.getContext("2d").drawImage(img, 0, sy, img.width, sh, 0, 0, nw, dh);
|
|
649
|
+
results.push(c.toDataURL("image/png").split(",")[1]);
|
|
650
|
+
}
|
|
651
|
+
return results;
|
|
652
|
+
}, { src: b64, nw: scaledW, totalH: scaledH, max: maxDim, count: chunkCount });
|
|
653
|
+
const dir = join(tmpdir(), `next-browser-screenshots-${Date.now()}`);
|
|
654
|
+
mkdirSync(dir, { recursive: true });
|
|
655
|
+
const files = [];
|
|
656
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
657
|
+
const p = join(dir, `${String(i + 1).padStart(3, "0")}.png`);
|
|
658
|
+
writeFileSync(p, Buffer.from(chunks[i], "base64"));
|
|
659
|
+
files.push(p);
|
|
660
|
+
}
|
|
661
|
+
// Clean up the original full file.
|
|
662
|
+
unlinkSync(filePath);
|
|
663
|
+
return { dir, files };
|
|
586
664
|
}
|
|
587
665
|
/** Remove Next.js devtools overlay from the page before screenshots. */
|
|
588
666
|
async function hideDevOverlay() {
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { send } from "./client.js";
|
|
6
|
+
import { parseCookies } from "./cookies.js";
|
|
6
7
|
const args = process.argv.slice(2);
|
|
7
8
|
const cmd = args[0];
|
|
8
9
|
const arg = args[1];
|
|
@@ -17,18 +18,29 @@ if (cmd === "--version" || cmd === "-v") {
|
|
|
17
18
|
}
|
|
18
19
|
if (cmd === "open") {
|
|
19
20
|
if (!arg) {
|
|
20
|
-
console.error("usage: next-browser open <url> [--cookies
|
|
21
|
+
console.error("usage: next-browser open <url> [--cookies <file>]");
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
23
24
|
const url = /^https?:\/\//.test(arg) ? arg : `http://${arg}`;
|
|
24
|
-
const cookieIdx =
|
|
25
|
+
const cookieIdx = (() => {
|
|
26
|
+
const i = args.indexOf("--cookies");
|
|
27
|
+
if (i >= 0)
|
|
28
|
+
return i;
|
|
29
|
+
return args.indexOf("--cookies-json"); // back-compat alias
|
|
30
|
+
})();
|
|
25
31
|
const cookieFile = cookieIdx >= 0 ? args[cookieIdx + 1] : undefined;
|
|
26
32
|
if (cookieFile) {
|
|
27
33
|
const res = await send("open");
|
|
28
34
|
if (!res.ok)
|
|
29
35
|
exit(res, "");
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
let cookies;
|
|
37
|
+
try {
|
|
38
|
+
cookies = parseCookies(readFileSync(cookieFile, "utf-8"));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
console.error(`cookies: ${e.message}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
32
44
|
const domain = new URL(url).hostname;
|
|
33
45
|
const cRes = await send("cookies", { cookies, domain });
|
|
34
46
|
if (!cRes.ok)
|
|
@@ -428,7 +440,7 @@ function pick(items) {
|
|
|
428
440
|
function printUsage() {
|
|
429
441
|
console.error("usage: next-browser <command> [args]\n" +
|
|
430
442
|
"\n" +
|
|
431
|
-
" open <url> [--cookies
|
|
443
|
+
" open <url> [--cookies <file>] launch browser and navigate\n" +
|
|
432
444
|
" close close browser and daemon\n" +
|
|
433
445
|
"\n" +
|
|
434
446
|
" goto <url> full-page navigation (new document load)\n" +
|
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse cookie input in one of three formats, auto-detected from the first
|
|
3
|
+
* non-whitespace character:
|
|
4
|
+
*
|
|
5
|
+
* 1. JSON array — Playwright-style `[{"name": "x", "value": "y"}, ...]`.
|
|
6
|
+
* 2. cURL command — as produced by DevTools → Network → Copy as cURL.
|
|
7
|
+
* The Cookie header is extracted from the `-H 'cookie: …'` argument.
|
|
8
|
+
* 3. Bare cookie header — `name=v; name=v; ...` (e.g. the value of the
|
|
9
|
+
* Cookie row in DevTools → Network → Request Headers).
|
|
10
|
+
*
|
|
11
|
+
* The parser deliberately never echoes the secret value back in an error
|
|
12
|
+
* message — error text mentions the format, not the contents.
|
|
13
|
+
*/
|
|
14
|
+
export function parseCookies(raw) {
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
if (!trimmed)
|
|
17
|
+
throw new Error("cookies file is empty");
|
|
18
|
+
if (trimmed[0] === "[") {
|
|
19
|
+
const arr = JSON.parse(trimmed);
|
|
20
|
+
if (!Array.isArray(arr))
|
|
21
|
+
throw new Error("cookies JSON must be an array");
|
|
22
|
+
return arr.map((c, i) => {
|
|
23
|
+
if (!c ||
|
|
24
|
+
typeof c.name !== "string" ||
|
|
25
|
+
typeof c.value !== "string") {
|
|
26
|
+
throw new Error(`cookies[${i}] must have string name and value`);
|
|
27
|
+
}
|
|
28
|
+
const { name, value } = c;
|
|
29
|
+
return { name, value };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (/^curl[\s'"]/i.test(trimmed)) {
|
|
33
|
+
const header = extractCookieHeaderFromCurl(trimmed);
|
|
34
|
+
if (!header) {
|
|
35
|
+
throw new Error("no Cookie header found in this cURL — right-click an authenticated request in DevTools → Network → Copy → Copy as cURL");
|
|
36
|
+
}
|
|
37
|
+
return parseCookieHeader(header);
|
|
38
|
+
}
|
|
39
|
+
return parseCookieHeader(trimmed);
|
|
40
|
+
}
|
|
41
|
+
function extractCookieHeaderFromCurl(curl) {
|
|
42
|
+
// Strip bash (`\`) and cmd (`^`) line continuations so the -H arg is on one line.
|
|
43
|
+
const joined = curl.replace(/\\\r?\n\s*/g, " ").replace(/\^\r?\n\s*/g, " ");
|
|
44
|
+
// -H 'cookie: …' (bash) or -H "cookie: …" (cmd). Chrome/Firefox use one
|
|
45
|
+
// or the other depending on which Copy-as-cURL variant the user picked.
|
|
46
|
+
const m = joined.match(/-H\s+(['"])\s*cookie\s*:\s*([\s\S]*?)\1/i);
|
|
47
|
+
return m ? m[2] : null;
|
|
48
|
+
}
|
|
49
|
+
function parseCookieHeader(header) {
|
|
50
|
+
const pairs = [];
|
|
51
|
+
for (const piece of header.split(/;\s*/)) {
|
|
52
|
+
const eq = piece.indexOf("=");
|
|
53
|
+
if (eq < 0)
|
|
54
|
+
continue;
|
|
55
|
+
const name = piece.slice(0, eq).trim();
|
|
56
|
+
const value = piece.slice(eq + 1).trim();
|
|
57
|
+
if (name)
|
|
58
|
+
pairs.push({ name, value });
|
|
59
|
+
}
|
|
60
|
+
if (pairs.length === 0)
|
|
61
|
+
throw new Error("no cookies found in input");
|
|
62
|
+
return pairs;
|
|
63
|
+
}
|