glance-cli 0.13.0 → 0.13.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,444 @@
1
+ /**
2
+ * Production-Grade Screenshot Utility
3
+ *
4
+ * Features:
5
+ * - Timeout protection (prevents hanging)
6
+ * - Retry logic for transient failures
7
+ * - Better error messages with actionable hints
8
+ * - Resource cleanup (prevents memory leaks)
9
+ * - Configurable viewport and quality
10
+ * - Support for different formats (PNG, JPEG, WebP)
11
+ * - Page load strategies
12
+ * - Error categorization
13
+ */
14
+
15
+ // Puppeteer types for when we dynamically import
16
+ type Browser = import("puppeteer").Browser;
17
+ type Page = import("puppeteer").Page;
18
+
19
+ import path from "node:path";
20
+
21
+ // === Configuration ===
22
+ const SCREENSHOT_CONFIG = {
23
+ DEFAULT_TIMEOUT: 30000, // 30s total timeout
24
+ PAGE_LOAD_TIMEOUT: 20000, // 20s for page load
25
+ VIEWPORT_WIDTH: 1920,
26
+ VIEWPORT_HEIGHT: 1080,
27
+ DEFAULT_FORMAT: "png" as const,
28
+ JPEG_QUALITY: 90,
29
+ MAX_RETRIES: 2,
30
+ RETRY_DELAY: 1000,
31
+ } as const;
32
+
33
+ // === Custom Error Types ===
34
+ class ScreenshotError extends Error {
35
+ constructor(
36
+ message: string,
37
+ public code: string,
38
+ public userMessage: string,
39
+ public hint?: string,
40
+ ) {
41
+ super(message);
42
+ this.name = "ScreenshotError";
43
+ }
44
+ }
45
+
46
+ // === Screenshot Options ===
47
+ export interface ScreenshotOptions {
48
+ /** Output file path (determines format from extension) */
49
+ filePath?: string;
50
+ /** Full page screenshot (default: true) */
51
+ fullPage?: boolean;
52
+ /** Viewport width (default: 1920) */
53
+ width?: number;
54
+ /** Viewport height (default: 1080) */
55
+ height?: number;
56
+ /** JPEG quality 0-100 (default: 90) */
57
+ quality?: number;
58
+ /** Timeout in milliseconds (default: 30000) */
59
+ timeout?: number;
60
+ /** Wait strategy: 'networkidle2' | 'networkidle0' | 'load' | 'domcontentloaded' */
61
+ waitUntil?: "networkidle2" | "networkidle0" | "load" | "domcontentloaded";
62
+ }
63
+
64
+ // === Helper Functions ===
65
+
66
+ /**
67
+ * Detect image format from file extension
68
+ */
69
+ function getImageFormat(filePath: string): "png" | "jpeg" | "webp" {
70
+ const ext = path.extname(filePath).toLowerCase();
71
+
72
+ switch (ext) {
73
+ case ".jpg":
74
+ case ".jpeg":
75
+ return "jpeg";
76
+ case ".webp":
77
+ return "webp";
78
+ default:
79
+ return "png";
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Validate URL format
85
+ */
86
+ function validateURL(url: string): void {
87
+ try {
88
+ const parsed = new URL(url);
89
+ if (!["http:", "https:"].includes(parsed.protocol)) {
90
+ throw new Error("Invalid protocol");
91
+ }
92
+ } catch {
93
+ throw new ScreenshotError(
94
+ `Invalid URL: ${url}`,
95
+ "INVALID_URL",
96
+ "Invalid URL format",
97
+ "URL must start with http:// or https://",
98
+ );
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Validate file path
104
+ */
105
+ function validateFilePath(filePath: string): void {
106
+ const ext = path.extname(filePath).toLowerCase();
107
+ const validExtensions = [".png", ".jpg", ".jpeg", ".webp"];
108
+
109
+ if (!validExtensions.includes(ext)) {
110
+ throw new ScreenshotError(
111
+ `Invalid file extension: ${ext}`,
112
+ "INVALID_EXTENSION",
113
+ "Unsupported image format",
114
+ `Supported formats: ${validExtensions.join(", ")}`,
115
+ );
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Launch browser with error handling
121
+ */
122
+ async function launchBrowser(
123
+ puppeteer: typeof import("puppeteer"),
124
+ ): Promise<Browser> {
125
+ try {
126
+ return await puppeteer.default.launch({
127
+ headless: true,
128
+ args: [
129
+ "--no-sandbox",
130
+ "--disable-setuid-sandbox",
131
+ "--disable-dev-shm-usage", // Prevent /dev/shm issues
132
+ "--disable-accelerated-2d-canvas",
133
+ "--no-first-run",
134
+ "--no-zygote",
135
+ "--disable-gpu",
136
+ ],
137
+ // Timeout for browser launch
138
+ timeout: 10000,
139
+ });
140
+ } catch (err: unknown) {
141
+ if (
142
+ err instanceof Error &&
143
+ (err.message.includes("Could not find") ||
144
+ err.message.includes("Chromium"))
145
+ ) {
146
+ throw new ScreenshotError(
147
+ err.message,
148
+ "BROWSER_NOT_FOUND",
149
+ "Chromium browser not found",
150
+ "Puppeteer's Chromium is missing. Reinstall: bun install puppeteer",
151
+ );
152
+ }
153
+
154
+ const errorMessage = err instanceof Error ? err.message : String(err);
155
+ throw new ScreenshotError(
156
+ errorMessage,
157
+ "BROWSER_LAUNCH_FAILED",
158
+ "Failed to launch browser",
159
+ "Check if you have enough system resources (memory, disk space)",
160
+ );
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Take screenshot with retry logic
166
+ */
167
+ async function captureWithRetry(
168
+ url: string,
169
+ options: Required<ScreenshotOptions>,
170
+ puppeteer: typeof import("puppeteer"),
171
+ attempt: number = 1,
172
+ ): Promise<void> {
173
+ let browser: Browser | undefined;
174
+ let page: Page | undefined;
175
+
176
+ try {
177
+ // Launch browser
178
+ browser = await launchBrowser(puppeteer);
179
+
180
+ // Create page with timeout
181
+ page = await browser.newPage();
182
+
183
+ // Set viewport
184
+ await page.setViewport({
185
+ width: options.width,
186
+ height: options.height,
187
+ deviceScaleFactor: 1,
188
+ });
189
+
190
+ // Set page timeout
191
+ page.setDefaultTimeout(options.timeout);
192
+ page.setDefaultNavigationTimeout(SCREENSHOT_CONFIG.PAGE_LOAD_TIMEOUT);
193
+
194
+ // Navigate to URL
195
+ try {
196
+ await page.goto(url, {
197
+ waitUntil: options.waitUntil,
198
+ timeout: SCREENSHOT_CONFIG.PAGE_LOAD_TIMEOUT,
199
+ });
200
+ } catch (err: unknown) {
201
+ // Navigation errors
202
+ if (
203
+ err instanceof Error &&
204
+ err.message.includes("net::ERR_NAME_NOT_RESOLVED")
205
+ ) {
206
+ throw new ScreenshotError(
207
+ err.message,
208
+ "DNS_ERROR",
209
+ "Cannot resolve domain name",
210
+ `Check if ${new URL(url).hostname} is accessible`,
211
+ );
212
+ }
213
+
214
+ if (
215
+ err instanceof Error &&
216
+ err.message.includes("net::ERR_CONNECTION_REFUSED")
217
+ ) {
218
+ throw new ScreenshotError(
219
+ err.message,
220
+ "CONNECTION_REFUSED",
221
+ "Connection refused",
222
+ "The server is not accepting connections. Check if the URL is correct.",
223
+ );
224
+ }
225
+
226
+ if (
227
+ err instanceof Error &&
228
+ (err.message.includes("Timeout") || err.message.includes("timeout"))
229
+ ) {
230
+ throw new ScreenshotError(
231
+ err.message,
232
+ "PAGE_TIMEOUT",
233
+ "Page took too long to load",
234
+ "The page is slow or unresponsive. Try again or check your connection.",
235
+ );
236
+ }
237
+
238
+ // Generic navigation error
239
+ const navErrorMessage = err instanceof Error ? err.message : String(err);
240
+ throw new ScreenshotError(
241
+ navErrorMessage,
242
+ "NAVIGATION_ERROR",
243
+ "Failed to load page",
244
+ "The page may be blocking automated access or has loading issues.",
245
+ );
246
+ }
247
+
248
+ // Determine format and options
249
+ const format = getImageFormat(options.filePath);
250
+ const screenshotOptions: Record<string, unknown> = {
251
+ path: options.filePath,
252
+ fullPage: options.fullPage,
253
+ type: format,
254
+ };
255
+
256
+ // Add quality for JPEG/WebP
257
+ if (format === "jpeg" || format === "webp") {
258
+ screenshotOptions.quality = options.quality;
259
+ }
260
+
261
+ // Take screenshot
262
+ await page.screenshot(screenshotOptions);
263
+
264
+ // Success - cleanup and return
265
+ await page.close();
266
+ await browser.close();
267
+ } catch (err: unknown) {
268
+ // Cleanup on error
269
+ if (page) {
270
+ try {
271
+ await page.close();
272
+ } catch {
273
+ // Ignore cleanup errors
274
+ }
275
+ }
276
+
277
+ if (browser) {
278
+ try {
279
+ await browser.close();
280
+ } catch {
281
+ // Ignore cleanup errors
282
+ }
283
+ }
284
+
285
+ // Retry on transient errors
286
+ if (err instanceof ScreenshotError && err.code === "PAGE_TIMEOUT") {
287
+ if (attempt < SCREENSHOT_CONFIG.MAX_RETRIES) {
288
+ const delay = SCREENSHOT_CONFIG.RETRY_DELAY * 2 ** (attempt - 1);
289
+ await new Promise((resolve) => setTimeout(resolve, delay));
290
+ return captureWithRetry(url, options, puppeteer, attempt + 1);
291
+ }
292
+ }
293
+
294
+ // Re-throw ScreenshotError as-is
295
+ if (err instanceof ScreenshotError) {
296
+ throw err;
297
+ }
298
+
299
+ // Wrap unknown errors
300
+ const unknownErrorMessage =
301
+ err instanceof Error ? err.message : String(err);
302
+ throw new ScreenshotError(
303
+ unknownErrorMessage || "Unknown error",
304
+ "SCREENSHOT_FAILED",
305
+ "Screenshot capture failed",
306
+ "An unexpected error occurred. Try again or check the URL.",
307
+ );
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Main screenshot function
313
+ *
314
+ * @param url - Target URL to screenshot
315
+ * @param options - Screenshot options
316
+ *
317
+ * @example
318
+ * // Basic usage
319
+ * await takeScreenshot("https://example.com");
320
+ *
321
+ * @example
322
+ * // Custom options
323
+ * await takeScreenshot("https://example.com", {
324
+ * filePath: "output.jpg",
325
+ * fullPage: false,
326
+ * quality: 85,
327
+ * width: 1280,
328
+ * height: 720,
329
+ * });
330
+ */
331
+ export async function takeScreenshot(
332
+ url: string,
333
+ options: ScreenshotOptions | string = {},
334
+ ): Promise<void> {
335
+ // Handle legacy string parameter (just filePath)
336
+ const opts: ScreenshotOptions =
337
+ typeof options === "string" ? { filePath: options } : options;
338
+
339
+ // Apply defaults
340
+ const finalOptions: Required<ScreenshotOptions> = {
341
+ filePath: opts.filePath || "screenshot.png",
342
+ fullPage: opts.fullPage ?? true,
343
+ width: opts.width || SCREENSHOT_CONFIG.VIEWPORT_WIDTH,
344
+ height: opts.height || SCREENSHOT_CONFIG.VIEWPORT_HEIGHT,
345
+ quality: opts.quality || SCREENSHOT_CONFIG.JPEG_QUALITY,
346
+ timeout: opts.timeout || SCREENSHOT_CONFIG.DEFAULT_TIMEOUT,
347
+ waitUntil: opts.waitUntil || "networkidle2",
348
+ };
349
+
350
+ // Validate inputs
351
+ validateURL(url);
352
+ validateFilePath(finalOptions.filePath);
353
+
354
+ // Validate dimensions
355
+ if (finalOptions.width <= 0 || finalOptions.height <= 0) {
356
+ throw new ScreenshotError(
357
+ "Invalid dimensions",
358
+ "INVALID_DIMENSIONS",
359
+ "Width and height must be positive",
360
+ "Use values like width: 1920, height: 1080",
361
+ );
362
+ }
363
+
364
+ // Validate quality for JPEG/WebP
365
+ const format = getImageFormat(finalOptions.filePath);
366
+ if (
367
+ (format === "jpeg" || format === "webp") &&
368
+ (finalOptions.quality < 0 || finalOptions.quality > 100)
369
+ ) {
370
+ throw new ScreenshotError(
371
+ "Invalid quality",
372
+ "INVALID_QUALITY",
373
+ "Quality must be between 0-100",
374
+ "Use values like quality: 90",
375
+ );
376
+ }
377
+
378
+ // Dynamically import Puppeteer and capture screenshot with retry
379
+ try {
380
+ const puppeteer = await import("puppeteer");
381
+ await captureWithRetry(url, finalOptions, puppeteer);
382
+ } catch (err: unknown) {
383
+ const message = err instanceof Error ? err.message : String(err);
384
+ throw new ScreenshotError(
385
+ `Puppeteer not available: ${message}`,
386
+ "PUPPETEER_MISSING",
387
+ "Screenshot functionality unavailable",
388
+ "Install Puppeteer: npm install puppeteer",
389
+ );
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Check if Puppeteer is available and working
395
+ */
396
+ export async function checkPuppeteerAvailability(): Promise<{
397
+ available: boolean;
398
+ error?: string;
399
+ hint?: string;
400
+ }> {
401
+ try {
402
+ const puppeteer = await import("puppeteer");
403
+ const browser = await puppeteer.default.launch({
404
+ headless: true,
405
+ args: ["--no-sandbox"],
406
+ timeout: 5000,
407
+ });
408
+ await browser.close();
409
+ return { available: true };
410
+ } catch (err: unknown) {
411
+ if (
412
+ err &&
413
+ typeof err === "object" &&
414
+ "message" in err &&
415
+ typeof err.message === "string" &&
416
+ (err.message.includes("Could not find") ||
417
+ err.message.includes("Chromium"))
418
+ ) {
419
+ return {
420
+ available: false,
421
+ error: "Chromium browser not found",
422
+ hint: "Reinstall Puppeteer: bun install puppeteer",
423
+ };
424
+ }
425
+ if (
426
+ err &&
427
+ typeof err === "object" &&
428
+ "message" in err &&
429
+ typeof err.message === "string" &&
430
+ err.message.includes("Cannot resolve module")
431
+ ) {
432
+ return {
433
+ available: false,
434
+ error: "Puppeteer not installed",
435
+ hint: "Install Puppeteer: npm install puppeteer",
436
+ };
437
+ }
438
+ return {
439
+ available: false,
440
+ error: "Puppeteer initialization failed",
441
+ hint: err instanceof Error ? err.message : String(err),
442
+ };
443
+ }
444
+ }