@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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Shared screenshot capture core: target classification, backend selection,
3
+ * and dispatch to the local (playwright-core, dynamic import) or remote
4
+ * (render endpoint) backend. Used by both the CLI command and the MCP tool.
5
+ *
6
+ * Deliberately NOT re-exported from index.ts/agent.ts — this module is safe
7
+ * to import statically (it never touches playwright-core itself), but
8
+ * keeping it out of the public entry points keeps the Worker-bundle
9
+ * constraint obvious and easy to audit with a grep.
10
+ */
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { basename, resolve as resolvePath } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { UploadsError } from "./errors.js";
15
+ import { captureRemote, MAX_REMOTE_HTML_BYTES } from "./screenshot-remote.js";
16
+ export const DEFAULT_SCREENSHOT_VIEWPORT = {
17
+ width: 1280,
18
+ height: 800,
19
+ deviceScaleFactor: 2,
20
+ };
21
+ /** Parses `WIDTHxHEIGHT[@SCALEx]`, e.g. "1280x800", "1280x800@2x", "1280x800@2". */
22
+ export function parseViewport(raw) {
23
+ if (!raw)
24
+ return DEFAULT_SCREENSHOT_VIEWPORT;
25
+ const match = /^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?)x?)?$/.exec(raw.trim());
26
+ if (!match) {
27
+ throw new UploadsError(`invalid viewport: ${raw} (expected WIDTHxHEIGHT[@SCALEx], e.g. 1280x800@2x)`, "USAGE");
28
+ }
29
+ const width = Number.parseInt(match[1], 10);
30
+ const height = Number.parseInt(match[2], 10);
31
+ const deviceScaleFactor = match[3] ? Number.parseFloat(match[3]) : 1;
32
+ if (width <= 0 || height <= 0 || deviceScaleFactor <= 0) {
33
+ throw new UploadsError(`invalid viewport: ${raw} (values must be positive)`, "USAGE");
34
+ }
35
+ return { width, height, deviceScaleFactor };
36
+ }
37
+ /** Parses `--wait`: "load" | "domcontentloaded" | "networkidle" | a millisecond count. */
38
+ export function parseWaitUntil(raw) {
39
+ if (!raw)
40
+ return "load";
41
+ if (raw === "load" || raw === "domcontentloaded" || raw === "networkidle")
42
+ return raw;
43
+ if (/^\d+$/.test(raw))
44
+ return Number.parseInt(raw, 10);
45
+ throw new UploadsError(`invalid wait strategy: ${raw} (use load, domcontentloaded, networkidle, or a millisecond count)`, "USAGE");
46
+ }
47
+ /** IPv4 loopback/private/link-local ranges. Mirrors the server's isPrivateRenderTarget. */
48
+ const PRIVATE_IPV4_RE = /^(127\.\d+\.\d+\.\d+|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+)$/;
49
+ /** Hostname forms treated as local/private regardless of DNS resolution. */
50
+ const PRIVATE_HOSTNAME_RE = /^((.+\.)?localhost|.+\.local|.+\.internal)$/i;
51
+ /** IPv6 unique local addresses, fc00::/7 (RFC 4193). */
52
+ const IPV6_ULA_RE = /^f[cd][0-9a-f]{2}:/i;
53
+ /** IPv6 link-local addresses, fe80::/10. */
54
+ const IPV6_LINK_LOCAL_RE = /^fe[89ab][0-9a-f]:/i;
55
+ function isPrivateIPv4(host) {
56
+ return PRIVATE_IPV4_RE.test(host);
57
+ }
58
+ /**
59
+ * True for localhost / private-network / link-local hosts — only reachable
60
+ * by the local backend. Accepts a bare hostname or an IPv6 literal with its
61
+ * brackets still attached (as returned by `new URL(...).hostname`, e.g.
62
+ * `"[::1]"`). Mirrors the server's `isPrivateRenderTarget` so `--via remote`
63
+ * fails fast for the same targets the render endpoint itself would reject.
64
+ */
65
+ export function isPrivateOrLocalHost(hostname) {
66
+ const host = /^\[.+\]$/.test(hostname) ? hostname.slice(1, -1) : hostname;
67
+ if (isPrivateIPv4(host))
68
+ return true;
69
+ if (PRIVATE_HOSTNAME_RE.test(host))
70
+ return true;
71
+ if (host === "::1" || host === "::")
72
+ return true;
73
+ if (IPV6_ULA_RE.test(host))
74
+ return true;
75
+ if (IPV6_LINK_LOCAL_RE.test(host))
76
+ return true;
77
+ // IPv4-mapped IPv6, e.g. "::ffff:10.0.0.1" or "::ffff:a00:1" — private iff
78
+ // the mapped IPv4 quad is private.
79
+ const mapped = /^::ffff:(.+)$/i.exec(host);
80
+ if (mapped) {
81
+ const rest = mapped[1];
82
+ if (isPrivateIPv4(rest))
83
+ return true;
84
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(rest);
85
+ if (hex) {
86
+ const hi = Number.parseInt(hex[1], 16);
87
+ const lo = Number.parseInt(hex[2], 16);
88
+ const a = (hi >> 8) & 0xff;
89
+ const b = hi & 0xff;
90
+ const c = (lo >> 8) & 0xff;
91
+ const d = lo & 0xff;
92
+ if (isPrivateIPv4(`${a}.${b}.${c}.${d}`))
93
+ return true;
94
+ }
95
+ }
96
+ return false;
97
+ }
98
+ /** Classifies a CLI target: http(s) URL, or a path to a local .html file. */
99
+ export function classifyTarget(target) {
100
+ if (/^https?:\/\//i.test(target)) {
101
+ let hostname;
102
+ try {
103
+ hostname = new URL(target).hostname;
104
+ }
105
+ catch {
106
+ throw new UploadsError(`invalid target URL: ${target}`, "USAGE");
107
+ }
108
+ return { kind: "url", url: target, localOnly: isPrivateOrLocalHost(hostname) };
109
+ }
110
+ const abs = resolvePath(target);
111
+ if (!existsSync(abs)) {
112
+ throw new UploadsError(`target not found: ${target} (expected a URL or an .html file)`, "USAGE");
113
+ }
114
+ if (!statSync(abs).isFile() || !/\.html?$/i.test(abs)) {
115
+ throw new UploadsError(`target must be an http(s) URL or an .html file (got ${target})`, "USAGE");
116
+ }
117
+ return { kind: "html-file", path: abs, html: readFileSync(abs, "utf8") };
118
+ }
119
+ /** Derives a filename from a URL (host+path) or the source .html filename. */
120
+ function deriveFilename(target) {
121
+ if (target.kind === "html-file") {
122
+ const stem = basename(target.path).replace(/\.html?$/i, "");
123
+ return `${stem || "screenshot"}.png`;
124
+ }
125
+ const url = new URL(target.url);
126
+ const pathPart = url.pathname
127
+ .replace(/\/+$/, "")
128
+ .replace(/^\/+/, "")
129
+ .replace(/[^A-Za-z0-9._-]+/g, "-");
130
+ const stem = [url.hostname, pathPart].filter(Boolean).join("-");
131
+ return `${stem || "screenshot"}.png`;
132
+ }
133
+ /**
134
+ * Best-effort local-browser probe used only to decide `auto` routing. Never
135
+ * throws — any failure (e.g. optional playwright-core not installed) means
136
+ * "no local browser available". Returns the full detection result (not just
137
+ * a boolean) so callers that go on to launch locally can reuse it instead of
138
+ * re-scanning the filesystem a second time.
139
+ */
140
+ async function probeLocalBrowser(detectRoots) {
141
+ try {
142
+ const { detectLocalBrowser } = await import("./screenshot-local.js");
143
+ return detectLocalBrowser(detectRoots);
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ }
149
+ /**
150
+ * Resolve target + options into PNG bytes via the local or remote backend.
151
+ * Shared by the CLI `screenshot` command and the MCP `screenshot` tool.
152
+ */
153
+ export async function captureScreenshot(opts) {
154
+ const target = classifyTarget(opts.target);
155
+ const viewport = opts.viewport ?? DEFAULT_SCREENSHOT_VIEWPORT;
156
+ const waitUntil = opts.waitUntil ?? "load";
157
+ const filename = deriveFilename(target);
158
+ // Only private-network URLs are truly unreachable remotely; an .html file
159
+ // is sent to the remote backend as an inline `html` body (though anything
160
+ // it references via file:// or relative paths won't resolve there).
161
+ const localOnly = target.kind === "url" && target.localOnly;
162
+ // Populated only when auto-routing actually probes the filesystem, so it
163
+ // can be threaded into captureLocalImpl below to avoid a second scan.
164
+ let detected;
165
+ let backend;
166
+ if (opts.via === "local") {
167
+ backend = "local";
168
+ }
169
+ else if (opts.via === "remote") {
170
+ if (localOnly) {
171
+ throw new UploadsError(`${opts.target} is only reachable by the local backend (localhost/private network, or a local file) — use --via local`, "USAGE");
172
+ }
173
+ backend = "remote";
174
+ }
175
+ else {
176
+ // auto
177
+ detected = await probeLocalBrowser(opts.detectRoots);
178
+ const available = Boolean(detected?.winner);
179
+ if (localOnly) {
180
+ if (!available) {
181
+ throw new UploadsError(`${opts.target} is only reachable by the local backend, but no local browser was found — install Chrome or run \`npx playwright install chromium\``, "BROWSER_NOT_FOUND");
182
+ }
183
+ backend = "local";
184
+ }
185
+ else {
186
+ backend = available ? "local" : "remote";
187
+ }
188
+ }
189
+ // Numeric --wait is a fixed post-load settle delay the local Playwright
190
+ // page can honor directly; the remote render endpoint only understands the
191
+ // named strategies, so fail fast instead of silently ignoring the delay.
192
+ if (backend === "remote" && typeof waitUntil === "number") {
193
+ throw new UploadsError(`numeric --wait (${waitUntil}ms) is local-only — use --via local, or one of load/domcontentloaded/networkidle for the remote backend`, "USAGE");
194
+ }
195
+ if (backend === "local") {
196
+ const captureLocalImpl = opts.captureLocalImpl ??
197
+ (async (localOpts) => {
198
+ const { captureLocal } = await import("./screenshot-local.js");
199
+ return captureLocal(localOpts);
200
+ });
201
+ const png = await captureLocalImpl({
202
+ url: target.kind === "html-file" ? pathToFileURL(target.path).href : target.url,
203
+ browserPath: opts.browserPath,
204
+ cdp: opts.cdp,
205
+ viewport,
206
+ selector: opts.selector,
207
+ fullPage: opts.fullPage,
208
+ colorScheme: opts.colorScheme,
209
+ waitUntil,
210
+ detectRoots: opts.detectRoots,
211
+ detectResult: detected,
212
+ });
213
+ return { png, filename, backend };
214
+ }
215
+ if (target.kind === "html-file") {
216
+ const bytes = new TextEncoder().encode(target.html).byteLength;
217
+ if (bytes > MAX_REMOTE_HTML_BYTES) {
218
+ throw new UploadsError(`${opts.target} is ${bytes} bytes, over the remote backend's ${MAX_REMOTE_HTML_BYTES} byte limit — use --via local`, "USAGE");
219
+ }
220
+ }
221
+ const captureRemoteImpl = opts.captureRemoteImpl ?? captureRemote;
222
+ const png = await captureRemoteImpl({
223
+ ...(target.kind === "html-file" ? { html: target.html } : { url: target.url }),
224
+ viewport,
225
+ selector: opts.selector,
226
+ fullPage: opts.fullPage,
227
+ colorScheme: opts.colorScheme,
228
+ waitUntil,
229
+ }, { apiUrl: opts.apiUrl, token: opts.token });
230
+ return { png, filename, backend };
231
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -58,6 +58,9 @@
58
58
  "dependencies": {
59
59
  "sharp": "^0.35.3"
60
60
  },
61
+ "optionalDependencies": {
62
+ "playwright-core": "^1.61.1"
63
+ },
61
64
  "scripts": {
62
65
  "test": "vitest run",
63
66
  "typecheck": "tsc --noEmit && tsc --noEmit -p test",