@miketromba/screenshot-service 0.1.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 ADDED
@@ -0,0 +1,316 @@
1
+ # Screenshot Service
2
+
3
+ A high-performance screenshot service that generates web page screenshots on-demand using Puppeteer. Distributed via NPM for easy integration into your projects.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@miketromba/screenshot-service?style=flat)](https://www.npmjs.com/package/@miketromba/screenshot-service)
6
+ [![Bun](https://img.shields.io/badge/Bun-000000?style=flat&logo=bun&logoColor=white)](https://bun.sh/)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
8
+ [![Vercel](https://img.shields.io/badge/Vercel-000000?style=flat&logo=vercel&logoColor=white)](https://vercel.com/)
9
+
10
+ ## Quick Start
11
+
12
+ ### Development (Local Server)
13
+
14
+ Run the screenshot service locally with a single command (requires [Bun](https://bun.sh)):
15
+
16
+ ```bash
17
+ # Start the server on port 3000
18
+ npx @miketromba/screenshot-service
19
+
20
+ # Or with options
21
+ npx @miketromba/screenshot-service --port 3001
22
+
23
+ # With authentication
24
+ AUTH_TOKEN=secret npx @miketromba/screenshot-service
25
+ ```
26
+
27
+ ### Production (Vercel)
28
+
29
+ Add the screenshot service to your existing Vercel/Next.js project:
30
+
31
+ ```bash
32
+ npm install @miketromba/screenshot-service
33
+ ```
34
+
35
+ **Next.js App Router** (`app/api/screenshot/route.ts`):
36
+ ```ts
37
+ export { GET } from '@miketromba/screenshot-service/vercel'
38
+ ```
39
+
40
+ **Vercel API Routes** (`api/screenshot.ts`):
41
+ ```ts
42
+ export { GET } from '@miketromba/screenshot-service/vercel'
43
+ ```
44
+
45
+ Add function configuration to your `vercel.json`:
46
+ ```json
47
+ {
48
+ "functions": {
49
+ "api/screenshot.ts": {
50
+ "maxDuration": 60,
51
+ "memory": 1024
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Set environment variables in Vercel dashboard:
58
+ - `AUTH_TOKEN` - Bearer token for authentication (optional)
59
+ - `HOST_WHITELIST` - Comma-separated allowed hostnames (optional)
60
+
61
+ ## Overview
62
+
63
+ The Screenshot Service provides an API for generating screenshots of web pages using Puppeteer. It supports two deployment modes:
64
+
65
+ - **Development**: Local Bun server with puppeteer-cluster for concurrent processing (up to 10 simultaneous screenshots)
66
+ - **Production**: Vercel serverless function using puppeteer-core + @sparticuz/chromium (scales horizontally)
67
+
68
+ ## Features
69
+
70
+ - On-demand screenshot generation
71
+ - Configurable screenshot dimensions
72
+ - Support for multiple image formats (PNG, WEBP, JPEG)
73
+ - Adjustable image quality
74
+ - Full page or viewport screenshots
75
+ - Concurrent request handling (up to 10 simultaneous screenshots)
76
+ - Bearer token authentication for secure access
77
+ - Hostname whitelist validation for enhanced security
78
+
79
+ ## Use Cases
80
+
81
+ ### Internal Application Screenshots
82
+ The service is particularly useful for capturing screenshots of authenticated internal applications. For example, in a design tool where previews are protected behind authentication:
83
+
84
+ 1. Set up your internal application with authentication
85
+ 2. Configure the screenshot service with the same authentication token
86
+ 3. Use the service to capture authenticated views of your application
87
+
88
+ Example scenario:
89
+ ```bash
90
+ # Your design tool has protected preview URLs like:
91
+ # https://design-tool.internal/preview/design-123
92
+ # These URLs require authentication to access
93
+
94
+ # Configure the screenshot service with your auth token
95
+ export AUTH_TOKEN=your-internal-auth-token
96
+
97
+ # The service will now be able to access and capture these protected previews
98
+ curl -H "Authorization: Bearer $AUTH_TOKEN" \
99
+ "http://localhost:3000/screenshot?url=https://design-tool.internal/preview/design-123" \
100
+ > design-preview.png
101
+ ```
102
+
103
+ This setup ensures that:
104
+ - Your internal application remains secure
105
+ - Only the screenshot service can access protected previews
106
+ - You can programmatically capture authenticated views of your application
107
+
108
+ ## Authentication
109
+
110
+ Authentication is optional and can be enabled by setting the `AUTH_TOKEN` environment variable. When enabled, all endpoints except the health check (`GET /`) require authentication using a Bearer token. The token must be included in the `Authorization` header of each request.
111
+
112
+ Format: `Authorization: Bearer <your-token>`
113
+
114
+ If authentication is enabled and the token is missing or invalid, the server will respond with:
115
+ - `401 Unauthorized`: Missing or invalid Authorization header format
116
+ - `403 Forbidden`: Invalid token
117
+
118
+ Note: When AUTH_TOKEN is set, it is also used to authenticate requests to the target websites when taking screenshots. This means the service will forward your authentication token to the websites you're capturing.
119
+
120
+ ## API Endpoints
121
+
122
+ The API is identical for both deployment options, with different base paths:
123
+
124
+ | Deployment | Health Check | Screenshot |
125
+ |------------|--------------|------------|
126
+ | Docker/Bun | `GET /` | `GET /screenshot` |
127
+ | Vercel | `GET /api` | `GET /api/screenshot` |
128
+
129
+ ### Health Check
130
+
131
+ Returns server status. This endpoint does not require authentication.
132
+
133
+ **Response:**
134
+ ```json
135
+ { "online": true }
136
+ ```
137
+
138
+ ### Take Screenshot
139
+
140
+ **Authentication Required**: Yes (Bearer token)
141
+
142
+ **Query Parameters:**
143
+
144
+ - `url` (required): The URL to screenshot (must be a valid URL)
145
+ - `fullPage` (optional): Whether to capture the full page or just the viewport
146
+ - Default: false
147
+ - Values: "true" or "false"
148
+ - `quality` (optional): Image quality (for JPEG and WEBP only)
149
+ - Default: 100
150
+ - Range: 1-100
151
+ - `type` (optional): Output image format
152
+ - Default: "png"
153
+ - Values: "png", "webp", "jpeg"
154
+ - `width` (optional): Viewport width in pixels
155
+ - Default: 1440
156
+ - Range: 1-1920
157
+ - `height` (optional): Viewport height in pixels
158
+ - Default: 900
159
+ - Range: 1-10000
160
+
161
+ #### Page Loading Options
162
+
163
+ The service provides several options to control when the screenshot is taken, ensuring content is fully loaded:
164
+
165
+ - `waitUntil` (optional): When to consider page navigation successful
166
+ - Default: "networkidle2"
167
+ - Values:
168
+ - `"load"` - Wait for the `load` event (all resources loaded)
169
+ - `"domcontentloaded"` - Wait for the `DOMContentLoaded` event (DOM is ready, but stylesheets/images may still be loading)
170
+ - `"networkidle0"` - Wait until there are no network connections for at least 500ms (strictest)
171
+ - `"networkidle2"` - Wait until there are no more than 2 network connections for at least 500ms (default, good balance)
172
+ - `waitForSelector` (optional): CSS selector to wait for before taking the screenshot
173
+ - Example: ".main-content" or "#hero-image"
174
+ - Timeout: 30 seconds
175
+ - Use this when you need to ensure a specific element has rendered
176
+ - `delay` (optional): Additional delay in milliseconds after page load before taking the screenshot
177
+ - Range: 0-30000 (0-30 seconds)
178
+ - Use this for animations, transitions, or slow-rendering content
179
+
180
+ **Note:** The service always waits for web fonts to load (`document.fonts.ready`) before taking screenshots to ensure text renders correctly.
181
+
182
+ **Response:**
183
+ - Content-Type: image/[type]
184
+ - Body: Binary image data
185
+
186
+ **Error Responses:**
187
+ - `403 Forbidden`: If the URL's hostname is not in the allowed whitelist
188
+
189
+ ## Environment Variables
190
+
191
+ - `PORT`: Server port number (default: 3000)
192
+ - `NODE_ENV`: Environment setting ("development" enables request logging)
193
+ - `AUTH_TOKEN`: Optional. When set, used for two purposes:
194
+ 1. The bearer token that clients must provide to access protected endpoints
195
+ 2. The authorization token forwarded to target websites when taking screenshots
196
+ - `MAX_CONCURRENCY`: Maximum number of concurrent screenshot operations (default: 10)
197
+ - `HOST_WHITELIST`: Comma-separated list of allowed hostnames. If empty, all hostnames are allowed
198
+
199
+ ## Technical Details
200
+
201
+ ### Local Development Server
202
+ - Built with [Hono](https://hono.dev/) web framework
203
+ - Uses [Puppeteer](https://pptr.dev/) for browser automation
204
+ - Implements [puppeteer-cluster](https://github.com/thomasdondorf/puppeteer-cluster) for concurrent processing
205
+ - Input validation using [Zod](https://zod.dev/)
206
+ - Requires [Bun](https://bun.sh) runtime
207
+
208
+ ### Vercel Serverless
209
+ - Serverless function with [puppeteer-core](https://pptr.dev/)
210
+ - Uses [@sparticuz/chromium](https://github.com/Sparticuz/chromium) for serverless-optimized Chromium
211
+ - Shared validation and screenshot logic
212
+
213
+ ## Docker Usage (Alternative)
214
+
215
+ For production deployments where you need full control, you can also run this as a Docker container.
216
+
217
+ ### Building the Image
218
+
219
+ You can build the Docker image using either the bun script or directly with Docker:
220
+
221
+ ```bash
222
+ # Using bun script (builds with tag: screenshot-service)
223
+ bun run docker:build
224
+
225
+ # Or directly with Docker
226
+ docker build -t screenshot-service .
227
+ ```
228
+
229
+ ### Running the Container
230
+
231
+ ```bash
232
+ docker run -d \
233
+ -p 3000:3000 \
234
+ -e AUTH_TOKEN=your-secret-token \
235
+ -e HOST_WHITELIST=example.com,test.com \
236
+ -e MAX_CONCURRENCY=10 \
237
+ screenshot-service
238
+ ```
239
+
240
+ ### Environment Variables
241
+
242
+ All environment variables can be passed to the container using the `-e` flag or by using a `.env` file:
243
+
244
+ ```bash
245
+ docker run -d \
246
+ -p 3000:3000 \
247
+ --env-file .env \
248
+ screenshot-service
249
+ ```
250
+
251
+ ### Accessing Local Services from Docker
252
+
253
+ When running the screenshot service in Docker and you need to capture screenshots of services running on your local machine, you need to:
254
+
255
+ 1. Add the `--add-host=host.docker.internal:host-gateway` flag when running the container
256
+ 2. Use `host.docker.internal` instead of `localhost` in your URLs
257
+
258
+ This is because `localhost` inside the container refers to the container itself, not your host machine.
259
+
260
+ Example:
261
+ ```bash
262
+ # ❌ Instead of:
263
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3000/screenshot?url=http://localhost:3001/my-app"
264
+
265
+ # ✅ Use:
266
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3000/screenshot?url=http://host.docker.internal:3001/my-app"
267
+ ```
268
+
269
+ Note: The `--add-host` flag is required for `host.docker.internal` to work. Make sure to include it in your `docker run` command.
270
+
271
+ ## Vercel Production Notes
272
+
273
+ ### Limitations
274
+
275
+ - **Cold starts**: First request may take 5-10 seconds (browser launch)
276
+ - **Timeout**: 60 seconds max (configurable up to 300s on Pro plan)
277
+ - **No custom fonts**: Unlike local development, Vercel functions don't include custom fonts. Screenshots may render with different fonts.
278
+ - **Scaling**: Vercel handles scaling automatically via parallel function invocations
279
+
280
+ ### Local Dev vs Vercel
281
+
282
+ | Feature | Local (npx) | Vercel |
283
+ |---------|-------------|--------|
284
+ | Concurrency | puppeteer-cluster (configurable) | Horizontal scaling |
285
+ | Cold start | None (always running) | 5-10 seconds |
286
+ | Custom fonts | System fonts available | Limited |
287
+ | Max timeout | Unlimited | 60-300 seconds |
288
+ | Cost | Free (local) | Pay per invocation |
289
+
290
+ ## Example Usage
291
+
292
+ ```bash
293
+ # Set your auth token
294
+ export AUTH_TOKEN=your-secret-token
295
+
296
+ # Basic screenshot
297
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3006/screenshot?url=https://example.com" > screenshot.png
298
+
299
+ # Full page JPEG screenshot with 80% quality
300
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3006/screenshot?url=https://example.com&fullPage=true&type=jpeg&quality=80" > screenshot.jpg
301
+
302
+ # Custom dimension WEBP screenshot
303
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3006/screenshot?url=https://example.com&width=1024&height=768&type=webp" > screenshot.webp
304
+
305
+ # Wait for all network activity to finish (strictest loading strategy)
306
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3006/screenshot?url=https://example.com&waitUntil=networkidle0" > screenshot.png
307
+
308
+ # Wait for a specific element to appear before taking screenshot
309
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3006/screenshot?url=https://example.com&waitForSelector=.main-content" > screenshot.png
310
+
311
+ # Add a 2-second delay for animations/transitions to complete
312
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3006/screenshot?url=https://example.com&delay=2000" > screenshot.png
313
+
314
+ # Combine multiple loading options for complex pages
315
+ curl -H "Authorization: Bearer $AUTH_TOKEN" "http://localhost:3006/screenshot?url=https://example.com&waitUntil=networkidle0&waitForSelector=#hero-image&delay=1000" > screenshot.png
316
+ ```
package/bin/cli.ts ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const args = process.argv.slice(2)
4
+
5
+ // Parse arguments
6
+ let port: number | undefined
7
+ let showHelp = false
8
+
9
+ for (let i = 0; i < args.length; i++) {
10
+ const arg = args[i]
11
+ if (arg === '--help' || arg === '-h') {
12
+ showHelp = true
13
+ } else if (arg === '--port' || arg === '-p') {
14
+ const portArg = args[++i]
15
+ if (portArg) {
16
+ port = parseInt(portArg, 10)
17
+ if (isNaN(port)) {
18
+ console.error(`Invalid port: ${portArg}`)
19
+ process.exit(1)
20
+ }
21
+ }
22
+ } else if (arg.startsWith('--port=')) {
23
+ port = parseInt(arg.split('=')[1], 10)
24
+ if (isNaN(port)) {
25
+ console.error(`Invalid port: ${arg.split('=')[1]}`)
26
+ process.exit(1)
27
+ }
28
+ }
29
+ }
30
+
31
+ if (showHelp) {
32
+ console.log(`
33
+ @miketromba/screenshot-service - A screenshot service powered by Puppeteer
34
+
35
+ Usage:
36
+ npx @miketromba/screenshot-service [options]
37
+
38
+ Options:
39
+ -p, --port <port> Port to run the server on (default: 3000, or PORT env var)
40
+ -h, --help Show this help message
41
+
42
+ Environment Variables:
43
+ PORT Server port (default: 3000)
44
+ AUTH_TOKEN Bearer token for authentication (optional)
45
+ HOST_WHITELIST Comma-separated list of allowed hostnames (optional)
46
+ MAX_CONCURRENCY Maximum concurrent screenshots (default: 10)
47
+ NODE_ENV Set to "development" for verbose logging
48
+
49
+ Examples:
50
+ # Start with default settings
51
+ npx @miketromba/screenshot-service
52
+
53
+ # Start on a specific port
54
+ npx @miketromba/screenshot-service --port 3001
55
+
56
+ # Start with authentication
57
+ AUTH_TOKEN=secret npx @miketromba/screenshot-service
58
+ `)
59
+ process.exit(0)
60
+ }
61
+
62
+ // Set port in environment if provided via CLI (takes precedence)
63
+ if (port !== undefined) {
64
+ process.env.PORT = String(port)
65
+ }
66
+
67
+ // Import and run the server
68
+ import('../src/server.ts')
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@miketromba/screenshot-service",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "screenshot-service": "./bin/cli.ts"
7
+ },
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/index.ts",
11
+ "types": "./src/index.ts"
12
+ },
13
+ "./vercel": {
14
+ "import": "./src/vercel.ts",
15
+ "types": "./src/vercel.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src"
21
+ ],
22
+ "scripts": {
23
+ "dev": "bun --watch src/server.ts",
24
+ "start": "bun run src/server.ts",
25
+ "docker:build": "docker build -t screenshot-service ."
26
+ },
27
+ "dependencies": {
28
+ "@hono/zod-validator": "^0.4.3",
29
+ "@sparticuz/chromium": "^143.0.4",
30
+ "hono": "^4.7.4",
31
+ "puppeteer-core": "^24.36.0",
32
+ "zod": "^3.24.2"
33
+ },
34
+ "optionalDependencies": {
35
+ "puppeteer": "^24.6.0",
36
+ "puppeteer-cluster": "^0.24.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "latest"
40
+ },
41
+ "trustedDependencies": [
42
+ "puppeteer"
43
+ ]
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Main package exports - shared utilities, types, and schemas
2
+ export {
3
+ // Schema and types
4
+ screenshotQuerySchema,
5
+ type ScreenshotQuery,
6
+ type ScreenshotOptions,
7
+ type AuthResult,
8
+ type Page,
9
+
10
+ // Utilities
11
+ getHostWhitelist,
12
+ getAuthToken,
13
+ isUrlHostnameAllowed,
14
+ queryToScreenshotOptions,
15
+ validateBearerToken,
16
+ captureScreenshot,
17
+
18
+ // Constants
19
+ CHROME_ARGS,
20
+ USER_AGENT,
21
+ SCREENSHOT_CSS
22
+ } from './shared'
package/src/server.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { Hono } from 'hono'
2
+ import { cors } from 'hono/cors'
3
+ import { logger } from 'hono/logger'
4
+ import { Cluster } from 'puppeteer-cluster'
5
+ import puppeteer from 'puppeteer'
6
+ import { zValidator } from '@hono/zod-validator'
7
+ import {
8
+ screenshotQuerySchema,
9
+ isUrlHostnameAllowed,
10
+ queryToScreenshotOptions,
11
+ validateBearerToken,
12
+ captureScreenshot,
13
+ getHostWhitelist,
14
+ getAuthToken,
15
+ CHROME_ARGS
16
+ } from './shared'
17
+
18
+ const MAX_CONCURRENCY = process.env.MAX_CONCURRENCY
19
+ ? parseInt(process.env.MAX_CONCURRENCY)
20
+ : 10
21
+ const HOST_WHITELIST = getHostWhitelist()
22
+ const AUTH_TOKEN = getAuthToken()
23
+ const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000
24
+ const DEV_MODE = process.env.NODE_ENV === 'development'
25
+
26
+ type PuppeteerOptions = Parameters<typeof puppeteer.launch>[0]
27
+
28
+ let cluster: Cluster<null, Uint8Array>
29
+
30
+ async function getCluster() {
31
+ if (!cluster) {
32
+ cluster = await Cluster.launch({
33
+ concurrency: Cluster.CONCURRENCY_CONTEXT,
34
+ maxConcurrency: MAX_CONCURRENCY,
35
+ puppeteerOptions: {
36
+ timeout: 300_000, // 5 minutes -- give it very generous timeout since loading browsers all at once on resource-bound VM is slow
37
+ headless: true,
38
+ args: CHROME_ARGS
39
+ } as PuppeteerOptions
40
+ })
41
+ }
42
+ return cluster
43
+ }
44
+
45
+ async function takeScreenshot(opts: Parameters<typeof captureScreenshot>[1]) {
46
+ const cluster = await getCluster()
47
+ return cluster.execute(null, async ({ page }) => {
48
+ return captureScreenshot(page, opts, AUTH_TOKEN)
49
+ })
50
+ }
51
+
52
+ const app = new Hono()
53
+
54
+ // Add common middleware
55
+ // Only use logger middleware in development environment
56
+ if (DEV_MODE) {
57
+ app.use('*', logger())
58
+ }
59
+
60
+ app.use('*', cors())
61
+
62
+ // Authentication middleware
63
+ const auth = async (c: any, next: any) => {
64
+ // Skip auth for health check endpoint
65
+ if (c.req.path === '/') {
66
+ return next()
67
+ }
68
+ const authResult = validateBearerToken(c.req.header('Authorization'), AUTH_TOKEN!)
69
+ if (authResult.valid === false) {
70
+ return c.json({ error: authResult.error }, authResult.status)
71
+ }
72
+ return next()
73
+ }
74
+
75
+ if (AUTH_TOKEN) {
76
+ app.use('*', auth)
77
+ }
78
+
79
+ app.get('/', c => {
80
+ return c.json({ online: true })
81
+ })
82
+
83
+ app.get(
84
+ '/screenshot',
85
+ zValidator('query', screenshotQuerySchema),
86
+ async c => {
87
+ const query = c.req.valid('query')
88
+
89
+ // Prod logging
90
+ if (!DEV_MODE) console.log('CAPTURE:', query.url)
91
+
92
+ // Check if the URL's hostname is allowed
93
+ if (!isUrlHostnameAllowed(query.url, HOST_WHITELIST)) {
94
+ return c.json(
95
+ {
96
+ error: `Hostname not allowed. Must be one of: ${HOST_WHITELIST.join(
97
+ ', '
98
+ )}`
99
+ },
100
+ 403
101
+ )
102
+ }
103
+
104
+ const screenshot = await takeScreenshot(queryToScreenshotOptions(query))
105
+
106
+ return new Response(Buffer.from(screenshot), {
107
+ headers: {
108
+ 'Content-Type': `image/${query.type}`
109
+ }
110
+ })
111
+ }
112
+ )
113
+
114
+ Bun.serve({
115
+ port: PORT,
116
+ fetch: app.fetch,
117
+ idleTimeout: 255 // max value, 5 mins
118
+ })
119
+
120
+ console.log(`Screenshot service is online on port ${PORT}`)
package/src/shared.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { z } from 'zod'
2
+ import type { Page as PuppeteerPage } from 'puppeteer'
3
+ import type { Page as PuppeteerCorePage } from 'puppeteer-core'
4
+
5
+ // Environment variables
6
+ export const getHostWhitelist = () =>
7
+ process.env.HOST_WHITELIST?.split(',').map(h => h.trim()) || []
8
+
9
+ export const getAuthToken = () => process.env.AUTH_TOKEN
10
+
11
+ // Query parameter schema for screenshot endpoint
12
+ export const screenshotQuerySchema = z.object({
13
+ url: z.string().url(),
14
+ fullPage: z.enum(['true', 'false']).optional().default('false'),
15
+ quality: z.coerce.number().min(1).max(100).optional().default(100),
16
+ type: z.enum(['png', 'webp', 'jpeg']).optional().default('png'),
17
+ width: z.coerce.number().min(1).max(1920).optional().default(1440),
18
+ height: z.coerce.number().min(1).max(10000).optional().default(900),
19
+ waitUntil: z
20
+ .enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2'])
21
+ .optional()
22
+ .default('networkidle2'),
23
+ waitForSelector: z.string().optional(),
24
+ delay: z.coerce.number().min(0).max(30000).optional()
25
+ })
26
+
27
+ export type ScreenshotQuery = z.infer<typeof screenshotQuerySchema>
28
+
29
+ // Screenshot options type used by both implementations
30
+ export type ScreenshotOptions = {
31
+ url: string
32
+ fullPage: boolean
33
+ quality: number
34
+ type: 'png' | 'webp' | 'jpeg'
35
+ dimensions: { width: number; height: number }
36
+ waitUntil: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'
37
+ waitForSelector?: string
38
+ delay?: number
39
+ }
40
+
41
+ // Check if URL hostname is in the whitelist
42
+ export function isUrlHostnameAllowed(
43
+ url: string,
44
+ whitelist: string[]
45
+ ): boolean {
46
+ try {
47
+ const hostname = new URL(url).hostname
48
+ if (!whitelist.length) {
49
+ return true
50
+ }
51
+ return whitelist.some(
52
+ allowed => hostname === allowed || hostname.endsWith(`.${allowed}`)
53
+ )
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ // Convert validated query params to screenshot options
60
+ export function queryToScreenshotOptions(
61
+ query: ScreenshotQuery
62
+ ): ScreenshotOptions {
63
+ return {
64
+ url: query.url,
65
+ fullPage: query.fullPage === 'true',
66
+ quality: query.quality,
67
+ type: query.type,
68
+ dimensions: {
69
+ width: query.width,
70
+ height: query.height
71
+ },
72
+ waitUntil: query.waitUntil,
73
+ waitForSelector: query.waitForSelector,
74
+ delay: query.delay
75
+ }
76
+ }
77
+
78
+ // Common Chrome launch arguments for consistent rendering
79
+ export const CHROME_ARGS = [
80
+ '--no-sandbox',
81
+ '--disable-setuid-sandbox',
82
+ '--font-render-hinting=none',
83
+ '--disable-font-subpixel-positioning',
84
+ '--force-color-profile=srgb'
85
+ ]
86
+
87
+ // User agent string
88
+ export const USER_AGENT =
89
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
90
+
91
+ // CSS for consistent font rendering
92
+ export const SCREENSHOT_CSS = `
93
+ * {
94
+ -webkit-print-color-adjust: exact !important;
95
+ text-rendering: geometricprecision !important;
96
+ -webkit-font-smoothing: antialiased !important;
97
+ }
98
+ `
99
+
100
+ // Page type from either puppeteer or puppeteer-core (both have same runtime API)
101
+ export type Page = PuppeteerPage | PuppeteerCorePage
102
+
103
+ // Capture screenshot using a page instance (shared logic for both implementations)
104
+ // Uses PuppeteerCorePage internally since both packages have identical runtime APIs
105
+ export async function captureScreenshot(
106
+ page: Page,
107
+ opts: ScreenshotOptions,
108
+ authToken?: string
109
+ ): Promise<Uint8Array> {
110
+ // Cast to PuppeteerCorePage to avoid union type signature conflicts
111
+ // Both puppeteer and puppeteer-core have identical APIs at runtime
112
+ const p = page as PuppeteerCorePage
113
+
114
+ // Use string format for compatibility with both puppeteer and puppeteer-core
115
+ await p.setUserAgent(USER_AGENT)
116
+
117
+ // Set authorization header for all requests
118
+ if (authToken) {
119
+ await p.setExtraHTTPHeaders({
120
+ Authorization: `Bearer ${authToken}`
121
+ })
122
+ }
123
+
124
+ await p.setViewport({
125
+ width: opts.dimensions.width,
126
+ height: opts.dimensions.height
127
+ })
128
+
129
+ await p.goto(opts.url, {
130
+ waitUntil: opts.waitUntil
131
+ })
132
+
133
+ // Set custom CSS to ensure consistent font rendering
134
+ await p.addStyleTag({
135
+ content: SCREENSHOT_CSS
136
+ })
137
+
138
+ // Wait for specific selector if provided
139
+ if (opts.waitForSelector) {
140
+ await p.waitForSelector(opts.waitForSelector, { timeout: 30000 })
141
+ }
142
+
143
+ // Wait for fonts to load
144
+ await p.evaluateHandle('document.fonts.ready')
145
+
146
+ // Additional delay if specified
147
+ if (opts.delay) {
148
+ await new Promise(resolve => setTimeout(resolve, opts.delay))
149
+ }
150
+
151
+ const screenshot = await p.screenshot({
152
+ type: opts.type,
153
+ ...(opts.type !== 'png' ? { quality: opts.quality } : {}),
154
+ fullPage: opts.fullPage
155
+ })
156
+
157
+ return screenshot as Uint8Array
158
+ }
159
+
160
+ // Auth validation result type
161
+ export type AuthResult =
162
+ | { valid: true }
163
+ | { valid: false; error: string; status: 401 | 403 }
164
+
165
+ // Validate Bearer token from Authorization header
166
+ export function validateBearerToken(
167
+ authHeader: string | null | undefined,
168
+ expectedToken: string
169
+ ): AuthResult {
170
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
171
+ return {
172
+ valid: false,
173
+ error: 'Authorization header missing or invalid format',
174
+ status: 401
175
+ }
176
+ }
177
+ const token = authHeader.split(' ')[1]
178
+ if (token !== expectedToken) {
179
+ return { valid: false, error: 'Invalid token', status: 403 }
180
+ }
181
+ return { valid: true }
182
+ }
package/src/vercel.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Vercel serverless function handler for screenshot service.
3
+ *
4
+ * Usage in your Vercel project:
5
+ *
6
+ * ```ts
7
+ * // api/screenshot.ts (or app/api/screenshot/route.ts for Next.js App Router)
8
+ * export { GET } from '@miketromba/screenshot-service/vercel'
9
+ * ```
10
+ */
11
+
12
+ import type { Browser, Page } from 'puppeteer-core'
13
+ import puppeteer from 'puppeteer-core'
14
+ import chromium from '@sparticuz/chromium'
15
+ import {
16
+ screenshotQuerySchema,
17
+ isUrlHostnameAllowed,
18
+ queryToScreenshotOptions,
19
+ validateBearerToken,
20
+ captureScreenshot,
21
+ getHostWhitelist,
22
+ getAuthToken,
23
+ CHROME_ARGS
24
+ } from './shared'
25
+
26
+ // Cache browser instance for warm invocations
27
+ let browser: Browser | null = null
28
+
29
+ async function getBrowser(): Promise<Browser> {
30
+ if (browser) {
31
+ return browser
32
+ }
33
+
34
+ browser = await puppeteer.launch({
35
+ args: [...chromium.args, ...CHROME_ARGS],
36
+ defaultViewport: null,
37
+ executablePath: await chromium.executablePath(),
38
+ headless: true
39
+ })
40
+
41
+ return browser
42
+ }
43
+
44
+ async function takeScreenshot(
45
+ opts: Parameters<typeof captureScreenshot>[1],
46
+ authToken?: string
47
+ ): Promise<Uint8Array> {
48
+ const browser = await getBrowser()
49
+ const page: Page = await browser.newPage()
50
+
51
+ try {
52
+ return await captureScreenshot(page, opts, authToken)
53
+ } finally {
54
+ await page.close()
55
+ }
56
+ }
57
+
58
+ /**
59
+ * GET handler for Vercel serverless functions.
60
+ * Re-export this from your API route.
61
+ */
62
+ export async function GET(request: Request): Promise<Response> {
63
+ const url = new URL(request.url)
64
+ const params = Object.fromEntries(url.searchParams.entries())
65
+
66
+ // Validate query parameters
67
+ const result = screenshotQuerySchema.safeParse(params)
68
+ if (!result.success) {
69
+ return Response.json(
70
+ {
71
+ error: 'Invalid query parameters',
72
+ details: result.error.flatten()
73
+ },
74
+ { status: 400 }
75
+ )
76
+ }
77
+
78
+ const query = result.data
79
+ const authToken = getAuthToken()
80
+ const hostWhitelist = getHostWhitelist()
81
+
82
+ // Check authentication if AUTH_TOKEN is set
83
+ if (authToken) {
84
+ const authResult = validateBearerToken(
85
+ request.headers.get('Authorization'),
86
+ authToken
87
+ )
88
+ if (authResult.valid === false) {
89
+ return Response.json(
90
+ { error: authResult.error },
91
+ { status: authResult.status }
92
+ )
93
+ }
94
+ }
95
+
96
+ // Check if the URL's hostname is allowed
97
+ if (!isUrlHostnameAllowed(query.url, hostWhitelist)) {
98
+ return Response.json(
99
+ {
100
+ error: `Hostname not allowed. Must be one of: ${hostWhitelist.join(', ')}`
101
+ },
102
+ { status: 403 }
103
+ )
104
+ }
105
+
106
+ console.log('CAPTURE:', query.url)
107
+
108
+ try {
109
+ const opts = queryToScreenshotOptions(query)
110
+ const screenshot = await takeScreenshot(opts, authToken)
111
+
112
+ return new Response(Buffer.from(screenshot), {
113
+ headers: {
114
+ 'Content-Type': `image/${query.type}`
115
+ }
116
+ })
117
+ } catch (error) {
118
+ console.error('Screenshot error:', error)
119
+ return Response.json(
120
+ {
121
+ error: 'Failed to capture screenshot',
122
+ message: error instanceof Error ? error.message : 'Unknown error'
123
+ },
124
+ { status: 500 }
125
+ )
126
+ }
127
+ }