@buildinternet/uploads 0.11.1 → 0.12.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/README.md +12 -1
- package/dist/cli-catalog.d.ts +7 -0
- package/dist/cli-catalog.js +51 -0
- package/dist/cli.js +12 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +13 -7
- package/dist/commands/completion.js +15 -5
- package/dist/commands/screenshot.d.ts +6 -0
- package/dist/commands/screenshot.js +317 -0
- package/dist/commands.d.ts +71 -1
- package/dist/commands.js +114 -28
- package/dist/config-file.d.ts +28 -2
- package/dist/config-file.js +44 -6
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/github-gh.js +6 -0
- package/dist/mcp/tools.d.ts +7 -0
- package/dist/mcp/tools.js +250 -27
- package/dist/screenshot-local.d.ts +64 -0
- package/dist/screenshot-local.js +310 -0
- package/dist/screenshot-remote.d.ts +23 -0
- package/dist/screenshot-remote.js +79 -0
- package/dist/screenshot.d.ts +74 -0
- package/dist/screenshot.js +231 -0
- package/package.json +4 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local screenshot backend — drives an already-installed Chrome/Chromium via
|
|
3
|
+
* `playwright-core` (no browser download; ~12 MB of JS).
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: `playwright-core` must never be statically imported. This module
|
|
6
|
+
* is Node-only and is itself only ever reached via dynamic `await import()`
|
|
7
|
+
* from callers (never from index.ts / agent.ts / mcp/server.ts) so the
|
|
8
|
+
* apps/mcp Cloudflare Worker — which bundles those three entry points — never
|
|
9
|
+
* pulls this file (or playwright-core) into its bundle.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { UploadsError } from "./errors.js";
|
|
15
|
+
function defaultPlaywrightCacheDir(env, platform) {
|
|
16
|
+
if (env.PLAYWRIGHT_BROWSERS_PATH)
|
|
17
|
+
return env.PLAYWRIGHT_BROWSERS_PATH;
|
|
18
|
+
if (platform === "darwin")
|
|
19
|
+
return join(homedir(), "Library", "Caches", "ms-playwright");
|
|
20
|
+
if (platform === "win32")
|
|
21
|
+
return join(homedir(), "AppData", "Local", "ms-playwright");
|
|
22
|
+
return join(homedir(), ".cache", "ms-playwright");
|
|
23
|
+
}
|
|
24
|
+
function defaultPuppeteerCacheDir(env, platform) {
|
|
25
|
+
if (env.PUPPETEER_CACHE_DIR)
|
|
26
|
+
return env.PUPPETEER_CACHE_DIR;
|
|
27
|
+
if (platform === "win32")
|
|
28
|
+
return join(homedir(), "AppData", "Local", "puppeteer", "cache");
|
|
29
|
+
return join(homedir(), ".cache", "puppeteer");
|
|
30
|
+
}
|
|
31
|
+
function defaultSystemCandidates(platform, env) {
|
|
32
|
+
if (platform === "darwin") {
|
|
33
|
+
return [
|
|
34
|
+
{ kind: "chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
|
|
35
|
+
{ kind: "edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
|
|
36
|
+
{ kind: "chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
|
|
37
|
+
{ kind: "brave", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
if (platform === "linux") {
|
|
41
|
+
return [
|
|
42
|
+
{ kind: "chrome", path: "/usr/bin/google-chrome" },
|
|
43
|
+
{ kind: "chrome", path: "/usr/bin/google-chrome-stable" },
|
|
44
|
+
{ kind: "chromium", path: "/usr/bin/chromium-browser" },
|
|
45
|
+
{ kind: "chromium", path: "/usr/bin/chromium" },
|
|
46
|
+
{ kind: "edge", path: "/usr/bin/microsoft-edge" },
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
if (platform === "win32") {
|
|
50
|
+
const pf = env.PROGRAMFILES ?? "C:\\Program Files";
|
|
51
|
+
const pf86 = env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
|
|
52
|
+
return [
|
|
53
|
+
{ kind: "chrome", path: join(pf, "Google", "Chrome", "Application", "chrome.exe") },
|
|
54
|
+
{ kind: "chrome", path: join(pf86, "Google", "Chrome", "Application", "chrome.exe") },
|
|
55
|
+
{ kind: "edge", path: join(pf, "Microsoft", "Edge", "Application", "msedge.exe") },
|
|
56
|
+
{ kind: "edge", path: join(pf86, "Microsoft", "Edge", "Application", "msedge.exe") },
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
/** Executable subpath inside a Playwright chromium/chromium_headless_shell revision dir. */
|
|
62
|
+
function playwrightExecutable(revisionDir, kind, platform, exists) {
|
|
63
|
+
const layouts = kind === "chromium"
|
|
64
|
+
? platform === "darwin"
|
|
65
|
+
? [
|
|
66
|
+
join(revisionDir, "chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
|
|
67
|
+
join(revisionDir, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
|
|
68
|
+
]
|
|
69
|
+
: platform === "linux"
|
|
70
|
+
? [join(revisionDir, "chrome-linux", "chrome")]
|
|
71
|
+
: platform === "win32"
|
|
72
|
+
? [join(revisionDir, "chrome-win", "chrome.exe")]
|
|
73
|
+
: []
|
|
74
|
+
: platform === "darwin"
|
|
75
|
+
? [join(revisionDir, "chrome-headless-shell-mac-arm64", "chrome-headless-shell")]
|
|
76
|
+
: platform === "linux"
|
|
77
|
+
? [join(revisionDir, "chrome-headless-shell-linux", "chrome-headless-shell")]
|
|
78
|
+
: platform === "win32"
|
|
79
|
+
? [join(revisionDir, "chrome-headless-shell-win", "chrome-headless-shell.exe")]
|
|
80
|
+
: [];
|
|
81
|
+
return layouts.find((p) => exists(p));
|
|
82
|
+
}
|
|
83
|
+
function scanPlaywrightCache(dir, platform, exists, readdir) {
|
|
84
|
+
if (!exists(dir))
|
|
85
|
+
return [];
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = readdir(dir);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const kind of ["chromium", "chromium_headless_shell"]) {
|
|
95
|
+
const matches = entries
|
|
96
|
+
.filter((e) => e.startsWith(`${kind}-`))
|
|
97
|
+
.map((e) => ({ name: e, rev: Number.parseInt(e.slice(kind.length + 1), 10) || 0 }))
|
|
98
|
+
.toSorted((a, b) => b.rev - a.rev); // newest revision first
|
|
99
|
+
for (const m of matches) {
|
|
100
|
+
const exe = playwrightExecutable(join(dir, m.name), kind, platform, exists);
|
|
101
|
+
if (exe)
|
|
102
|
+
out.push({
|
|
103
|
+
source: "playwright-cache",
|
|
104
|
+
kind,
|
|
105
|
+
executablePath: exe,
|
|
106
|
+
revision: String(m.rev),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function puppeteerExecutable(buildDir, kind, platform, exists) {
|
|
113
|
+
let sub;
|
|
114
|
+
if (platform === "darwin")
|
|
115
|
+
sub = kind === "chrome" ? "chrome-mac-arm64" : "chrome-headless-shell-mac-arm64";
|
|
116
|
+
else if (platform === "win32")
|
|
117
|
+
sub = kind === "chrome" ? "chrome-win64" : "chrome-headless-shell-win64";
|
|
118
|
+
else
|
|
119
|
+
sub = kind === "chrome" ? "chrome-linux64" : "chrome-headless-shell-linux64";
|
|
120
|
+
let exe;
|
|
121
|
+
if (kind === "chrome") {
|
|
122
|
+
exe =
|
|
123
|
+
platform === "darwin"
|
|
124
|
+
? join(buildDir, sub, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing")
|
|
125
|
+
: platform === "win32"
|
|
126
|
+
? join(buildDir, sub, "chrome.exe")
|
|
127
|
+
: join(buildDir, sub, "chrome");
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
exe = join(buildDir, sub, platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell");
|
|
131
|
+
}
|
|
132
|
+
return exists(exe) ? exe : undefined;
|
|
133
|
+
}
|
|
134
|
+
function scanPuppeteerCache(dir, platform, exists, readdir) {
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const kind of ["chrome", "chrome-headless-shell"]) {
|
|
137
|
+
const kindDir = join(dir, kind);
|
|
138
|
+
if (!exists(kindDir))
|
|
139
|
+
continue;
|
|
140
|
+
let builds;
|
|
141
|
+
try {
|
|
142
|
+
builds = readdir(kindDir);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
builds = builds
|
|
148
|
+
.filter((b) => exists(join(kindDir, b)))
|
|
149
|
+
.sort((a, b) => {
|
|
150
|
+
const va = (a.match(/[\d.]+$/) ?? ["0"])[0];
|
|
151
|
+
const vb = (b.match(/[\d.]+$/) ?? ["0"])[0];
|
|
152
|
+
return vb.localeCompare(va, undefined, { numeric: true });
|
|
153
|
+
});
|
|
154
|
+
for (const b of builds) {
|
|
155
|
+
const exe = puppeteerExecutable(join(kindDir, b), kind, platform, exists);
|
|
156
|
+
if (exe)
|
|
157
|
+
out.push({ source: "puppeteer-cache", kind, executablePath: exe, revision: b });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Scan for a usable local Chromium-family executable. Pure fs/env — never
|
|
164
|
+
* launches a browser. Roots are injectable so tests can fake a cache layout.
|
|
165
|
+
*/
|
|
166
|
+
export function detectLocalBrowser(roots = {}) {
|
|
167
|
+
const platform = roots.platform ?? process.platform;
|
|
168
|
+
const env = roots.env ?? process.env;
|
|
169
|
+
const exists = roots.exists ?? existsSync;
|
|
170
|
+
const readdir = roots.readdir ?? ((p) => readdirSync(p));
|
|
171
|
+
const envOverride = env.UPLOADS_CHROME_PATH || env.CHROME_PATH || undefined;
|
|
172
|
+
const candidates = [];
|
|
173
|
+
if (envOverride && exists(envOverride)) {
|
|
174
|
+
candidates.push({ source: "env", kind: "env-override", executablePath: envOverride });
|
|
175
|
+
}
|
|
176
|
+
const systemCandidates = roots.systemCandidates ?? defaultSystemCandidates(platform, env);
|
|
177
|
+
for (const c of systemCandidates) {
|
|
178
|
+
if (exists(c.path))
|
|
179
|
+
candidates.push({ source: "system", kind: c.kind, executablePath: c.path });
|
|
180
|
+
}
|
|
181
|
+
candidates.push(...scanPlaywrightCache(roots.playwrightCacheDir ?? defaultPlaywrightCacheDir(env, platform), platform, exists, readdir));
|
|
182
|
+
candidates.push(...scanPuppeteerCache(roots.puppeteerCacheDir ?? defaultPuppeteerCacheDir(env, platform), platform, exists, readdir));
|
|
183
|
+
// Ranking: env override > system Chrome (channel:'chrome' launch target,
|
|
184
|
+
// preferred default) > playwright cache chromium > puppeteer cache chrome >
|
|
185
|
+
// any other system browser > headless-shell builds last (see brief).
|
|
186
|
+
const rank = (c) => {
|
|
187
|
+
if (c.source === "env")
|
|
188
|
+
return 0;
|
|
189
|
+
if (c.source === "system" && c.kind === "chrome")
|
|
190
|
+
return 1;
|
|
191
|
+
if (c.source === "playwright-cache" && c.kind === "chromium")
|
|
192
|
+
return 2;
|
|
193
|
+
if (c.source === "puppeteer-cache" && c.kind === "chrome")
|
|
194
|
+
return 3;
|
|
195
|
+
if (c.source === "system")
|
|
196
|
+
return 4;
|
|
197
|
+
if (c.source === "playwright-cache" && c.kind === "chromium_headless_shell")
|
|
198
|
+
return 5;
|
|
199
|
+
if (c.source === "puppeteer-cache" && c.kind === "chrome-headless-shell")
|
|
200
|
+
return 6;
|
|
201
|
+
return 9;
|
|
202
|
+
};
|
|
203
|
+
const winner = [...candidates].toSorted((a, b) => rank(a) - rank(b))[0];
|
|
204
|
+
return { envOverride, candidates, winner };
|
|
205
|
+
}
|
|
206
|
+
async function loadPlaywrightCore() {
|
|
207
|
+
try {
|
|
208
|
+
// Dynamic import only — never hoist this to a static `import` statement.
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
210
|
+
return (await import("playwright-core"));
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
throw new UploadsError(`no local browser runtime available (playwright-core failed to load: ${err instanceof Error ? err.message : String(err)})`, "BROWSER_NOT_FOUND");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Launch a browser per the documented detection order. Never throws BROWSER_NOT_FOUND for a bad candidate — falls through to the next. */
|
|
217
|
+
async function launchLocalBrowser(chromium, opts) {
|
|
218
|
+
if (opts.browserPath) {
|
|
219
|
+
return chromium.launch({ executablePath: opts.browserPath, headless: true });
|
|
220
|
+
}
|
|
221
|
+
const detected = opts.detectResult ?? detectLocalBrowser(opts.detectRoots);
|
|
222
|
+
if (detected.envOverride) {
|
|
223
|
+
try {
|
|
224
|
+
return await chromium.launch({ executablePath: detected.envOverride, headless: true });
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// fall through
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Preferred default: let playwright-core resolve system Chrome itself.
|
|
231
|
+
try {
|
|
232
|
+
return await chromium.launch({ channel: "chrome", headless: true });
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// fall through
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
return await chromium.launch({ channel: "msedge", headless: true });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// fall through
|
|
242
|
+
}
|
|
243
|
+
for (const candidate of detected.candidates) {
|
|
244
|
+
if (candidate.source === "env")
|
|
245
|
+
continue; // already tried above
|
|
246
|
+
try {
|
|
247
|
+
return await chromium.launch({ executablePath: candidate.executablePath, headless: true });
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// try the next candidate
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw new UploadsError("no local browser found — install Chrome, or run `npx playwright install chromium`", "BROWSER_NOT_FOUND");
|
|
254
|
+
}
|
|
255
|
+
/** Capture a PNG screenshot using a local (already-installed) browser. */
|
|
256
|
+
export async function captureLocal(opts) {
|
|
257
|
+
const { chromium } = await loadPlaywrightCore();
|
|
258
|
+
let browser;
|
|
259
|
+
let usingCdp = false;
|
|
260
|
+
if (opts.cdp) {
|
|
261
|
+
try {
|
|
262
|
+
browser = await chromium.connectOverCDP(opts.cdp);
|
|
263
|
+
usingCdp = true;
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
throw new UploadsError(`could not connect to CDP endpoint ${opts.cdp}: ${err instanceof Error ? err.message : String(err)}`, "BROWSER_NOT_FOUND");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
try {
|
|
271
|
+
browser = await launchLocalBrowser(chromium, opts);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
if (err instanceof UploadsError)
|
|
275
|
+
throw err;
|
|
276
|
+
throw new UploadsError(`could not launch a local browser: ${err instanceof Error ? err.message : String(err)}`, "BROWSER_NOT_FOUND");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
// deviceScaleFactor cannot be set on an existing CDP context — the Chrome
|
|
281
|
+
// process itself must have been launched with --force-device-scale-factor.
|
|
282
|
+
const context = usingCdp
|
|
283
|
+
? (browser.contexts()[0] ?? (await browser.newContext()))
|
|
284
|
+
: await browser.newContext({
|
|
285
|
+
viewport: { width: opts.viewport.width, height: opts.viewport.height },
|
|
286
|
+
deviceScaleFactor: opts.viewport.deviceScaleFactor,
|
|
287
|
+
colorScheme: opts.colorScheme,
|
|
288
|
+
});
|
|
289
|
+
const page = usingCdp
|
|
290
|
+
? await context.newPage()
|
|
291
|
+
: (context.pages()[0] ?? (await context.newPage()));
|
|
292
|
+
if (usingCdp) {
|
|
293
|
+
await page.setViewportSize({ width: opts.viewport.width, height: opts.viewport.height });
|
|
294
|
+
if (opts.colorScheme)
|
|
295
|
+
await page.emulateMedia({ colorScheme: opts.colorScheme });
|
|
296
|
+
}
|
|
297
|
+
const waitUntil = typeof opts.waitUntil === "string" ? opts.waitUntil : "load";
|
|
298
|
+
await page.goto(opts.url, { waitUntil, timeout: opts.timeoutMs ?? 30_000 });
|
|
299
|
+
if (typeof opts.waitUntil === "number")
|
|
300
|
+
await page.waitForTimeout(opts.waitUntil);
|
|
301
|
+
const png = opts.selector
|
|
302
|
+
? await page.locator(opts.selector).screenshot({ timeout: opts.timeoutMs ?? 30_000 })
|
|
303
|
+
: await page.screenshot({ fullPage: opts.fullPage === true });
|
|
304
|
+
// Buffer extends Uint8Array — return it as-is rather than copying.
|
|
305
|
+
return png;
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
await browser.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Matches the brief's remote-backend cap for inline HTML bodies. */
|
|
2
|
+
export declare const MAX_REMOTE_HTML_BYTES: number;
|
|
3
|
+
export interface RemoteRenderRequest {
|
|
4
|
+
url?: string;
|
|
5
|
+
html?: string;
|
|
6
|
+
viewport: {
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
deviceScaleFactor: number;
|
|
10
|
+
};
|
|
11
|
+
selector?: string;
|
|
12
|
+
fullPage?: boolean;
|
|
13
|
+
colorScheme?: "dark" | "light";
|
|
14
|
+
waitUntil?: "load" | "domcontentloaded" | "networkidle" | number;
|
|
15
|
+
}
|
|
16
|
+
export interface RemoteRenderOptions {
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
token: string;
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
/** POST the render request; resolves with raw PNG bytes on 200. */
|
|
23
|
+
export declare function captureRemote(body: RemoteRenderRequest, opts: RemoteRenderOptions): Promise<Uint8Array>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote screenshot backend — POSTs to the workspace-authed render endpoint
|
|
3
|
+
* and gets raw PNG bytes back. No local browser required. Network-only; safe
|
|
4
|
+
* to import statically anywhere (no native/optional deps).
|
|
5
|
+
*/
|
|
6
|
+
import { parseErrorEnvelope } from "./client.js";
|
|
7
|
+
import { UploadsError } from "./errors.js";
|
|
8
|
+
const POST_TIMEOUT_MS = 30_000;
|
|
9
|
+
/** Matches the brief's remote-backend cap for inline HTML bodies. */
|
|
10
|
+
export const MAX_REMOTE_HTML_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Maps the render endpoint's error codes onto CLI error codes. Mirrors
|
|
13
|
+
* client.ts's mapApiError conventions: `render_failed` -> RENDER_FAILED;
|
|
14
|
+
* render-budget denials arrive as the EXISTING `upload_budget_exceeded` code
|
|
15
|
+
* (renders draw from the monthly upload budget — no separate RENDER_BUDGET
|
|
16
|
+
* code), so they fall into the shared UPLOAD_BUDGET mapping/hints. The
|
|
17
|
+
* server's burst limiter reports `rate_limited` explicitly; a bare 429 with
|
|
18
|
+
* no body code is also treated as a throttle (more likely than a budget
|
|
19
|
+
* denial), both mapping to RATE_LIMITED — checked before the generic 429
|
|
20
|
+
* fallback so an explicit `upload_budget_exceeded` on a 429 still wins.
|
|
21
|
+
*/
|
|
22
|
+
function mapRenderError(status, message, code) {
|
|
23
|
+
if (status === 401 || code === "unauthorized")
|
|
24
|
+
return new UploadsError(message, "UNAUTHORIZED", status);
|
|
25
|
+
if (code === "upload_budget_exceeded")
|
|
26
|
+
return new UploadsError(message, "UPLOAD_BUDGET", status);
|
|
27
|
+
if (code === "render_failed")
|
|
28
|
+
return new UploadsError(message, "RENDER_FAILED", status);
|
|
29
|
+
if (code === "rate_limited")
|
|
30
|
+
return new UploadsError(message, "RATE_LIMITED", status);
|
|
31
|
+
if (status === 429)
|
|
32
|
+
return new UploadsError(message, "RATE_LIMITED", status);
|
|
33
|
+
return new UploadsError(message, "API_ERROR", status);
|
|
34
|
+
}
|
|
35
|
+
/** POST the render request; resolves with raw PNG bytes on 200. */
|
|
36
|
+
export async function captureRemote(body, opts) {
|
|
37
|
+
if (body.html !== undefined) {
|
|
38
|
+
const htmlBytes = new TextEncoder().encode(body.html).byteLength;
|
|
39
|
+
if (htmlBytes > MAX_REMOTE_HTML_BYTES) {
|
|
40
|
+
throw new UploadsError(`html body exceeds the remote backend's ${MAX_REMOTE_HTML_BYTES} byte limit (use --via local instead)`, "USAGE");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const base = opts.apiUrl.replace(/\/$/, "");
|
|
44
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? POST_TIMEOUT_MS);
|
|
47
|
+
// The body reads stay inside the abort-guarded scope: the timer must cover
|
|
48
|
+
// the full response (a server stalling mid-stream would otherwise hang
|
|
49
|
+
// res.arrayBuffer() forever once the headers had arrived).
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetchImpl(`${base}/v1/render`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
"content-type": "application/json",
|
|
55
|
+
authorization: `Bearer ${opts.token}`,
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const { message, code } = await parseErrorEnvelope(res, "render failed");
|
|
62
|
+
throw mapRenderError(res.status, message, code);
|
|
63
|
+
}
|
|
64
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
65
|
+
if (bytes.byteLength === 0) {
|
|
66
|
+
throw new UploadsError("render endpoint returned an empty response", "RENDER_FAILED");
|
|
67
|
+
}
|
|
68
|
+
return bytes;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (err instanceof UploadsError)
|
|
72
|
+
throw err;
|
|
73
|
+
const message = err instanceof Error ? err.message : "network request failed";
|
|
74
|
+
throw new UploadsError(message.includes("abort") ? "render request timed out" : message, "NETWORK");
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { captureRemote } from "./screenshot-remote.js";
|
|
2
|
+
import type { DetectRoots } from "./screenshot-local.js";
|
|
3
|
+
export type ScreenshotBackend = "auto" | "local" | "remote";
|
|
4
|
+
export type WaitUntil = "load" | "domcontentloaded" | "networkidle" | number;
|
|
5
|
+
export interface ScreenshotViewport {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
deviceScaleFactor: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const DEFAULT_SCREENSHOT_VIEWPORT: ScreenshotViewport;
|
|
11
|
+
/** Parses `WIDTHxHEIGHT[@SCALEx]`, e.g. "1280x800", "1280x800@2x", "1280x800@2". */
|
|
12
|
+
export declare function parseViewport(raw: string | undefined): ScreenshotViewport;
|
|
13
|
+
/** Parses `--wait`: "load" | "domcontentloaded" | "networkidle" | a millisecond count. */
|
|
14
|
+
export declare function parseWaitUntil(raw: string | undefined): WaitUntil;
|
|
15
|
+
export type ScreenshotTarget = {
|
|
16
|
+
kind: "url";
|
|
17
|
+
url: string;
|
|
18
|
+
localOnly: boolean;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "html-file";
|
|
21
|
+
path: string;
|
|
22
|
+
html: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* True for localhost / private-network / link-local hosts — only reachable
|
|
26
|
+
* by the local backend. Accepts a bare hostname or an IPv6 literal with its
|
|
27
|
+
* brackets still attached (as returned by `new URL(...).hostname`, e.g.
|
|
28
|
+
* `"[::1]"`). Mirrors the server's `isPrivateRenderTarget` so `--via remote`
|
|
29
|
+
* fails fast for the same targets the render endpoint itself would reject.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isPrivateOrLocalHost(hostname: string): boolean;
|
|
32
|
+
/** Classifies a CLI target: http(s) URL, or a path to a local .html file. */
|
|
33
|
+
export declare function classifyTarget(target: string): ScreenshotTarget;
|
|
34
|
+
export interface CaptureScreenshotOptions {
|
|
35
|
+
target: string;
|
|
36
|
+
via: ScreenshotBackend;
|
|
37
|
+
browserPath?: string;
|
|
38
|
+
cdp?: string;
|
|
39
|
+
viewport?: ScreenshotViewport;
|
|
40
|
+
selector?: string;
|
|
41
|
+
fullPage?: boolean;
|
|
42
|
+
colorScheme?: "dark" | "light";
|
|
43
|
+
waitUntil?: WaitUntil;
|
|
44
|
+
apiUrl: string;
|
|
45
|
+
token: string;
|
|
46
|
+
/** Injectable for tests; forwarded to detectLocalBrowser. */
|
|
47
|
+
detectRoots?: DetectRoots;
|
|
48
|
+
/** Injectable for tests: replaces the local capture implementation. */
|
|
49
|
+
captureLocalImpl?: (opts: {
|
|
50
|
+
url: string;
|
|
51
|
+
browserPath?: string;
|
|
52
|
+
cdp?: string;
|
|
53
|
+
viewport: ScreenshotViewport;
|
|
54
|
+
selector?: string;
|
|
55
|
+
fullPage?: boolean;
|
|
56
|
+
colorScheme?: "dark" | "light";
|
|
57
|
+
waitUntil: WaitUntil;
|
|
58
|
+
detectRoots?: DetectRoots;
|
|
59
|
+
/** Pre-computed detection result from auto-routing, to avoid a second fs scan. */
|
|
60
|
+
detectResult?: import("./screenshot-local.js").DetectResult;
|
|
61
|
+
}) => Promise<Uint8Array>;
|
|
62
|
+
/** Injectable for tests: replaces the remote capture implementation. */
|
|
63
|
+
captureRemoteImpl?: typeof captureRemote;
|
|
64
|
+
}
|
|
65
|
+
export interface CaptureScreenshotResult {
|
|
66
|
+
png: Uint8Array;
|
|
67
|
+
filename: string;
|
|
68
|
+
backend: "local" | "remote";
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve target + options into PNG bytes via the local or remote backend.
|
|
72
|
+
* Shared by the CLI `screenshot` command and the MCP `screenshot` tool.
|
|
73
|
+
*/
|
|
74
|
+
export declare function captureScreenshot(opts: CaptureScreenshotOptions): Promise<CaptureScreenshotResult>;
|