@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 +316 -0
- package/bin/cli.ts +68 -0
- package/package.json +44 -0
- package/src/index.ts +22 -0
- package/src/server.ts +120 -0
- package/src/shared.ts +182 -0
- package/src/vercel.ts +127 -0
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
|
+
[](https://www.npmjs.com/package/@miketromba/screenshot-service)
|
|
6
|
+
[](https://bun.sh/)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](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
|
+
}
|