@miketromba/screenshot-service 0.2.2 → 0.2.6
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 +25 -9
- package/bin/cli.ts +0 -0
- package/package.json +7 -11
- package/src/vercel.ts +2 -7
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -8
- package/dist/server.d.ts +0 -2
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -84
- package/dist/shared.d.ts +0 -66
- package/dist/shared.d.ts.map +0 -1
- package/dist/shared.js +0 -123
- package/dist/vercel.d.ts +0 -16
- package/dist/vercel.d.ts.map +0 -1
- package/dist/vercel.js +0 -89
package/README.md
CHANGED
|
@@ -24,25 +24,39 @@ npx @miketromba/screenshot-service --port 3001
|
|
|
24
24
|
SCREENSHOT_AUTH_TOKEN=secret npx @miketromba/screenshot-service
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
### Production (Vercel)
|
|
27
|
+
### Production (Vercel + Next.js)
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Deploy the screenshot service as part of your Next.js project on Vercel.
|
|
30
|
+
|
|
31
|
+
> **Note:** This package ships raw TypeScript files. You must configure Next.js to transpile it.
|
|
30
32
|
|
|
31
33
|
```bash
|
|
32
34
|
npm install @miketromba/screenshot-service
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
**Next.js
|
|
37
|
+
**Step 1: Configure Next.js to transpile the package** (`next.config.js` or `next.config.ts`):
|
|
38
|
+
```js
|
|
39
|
+
/** @type {import('next').NextConfig} */
|
|
40
|
+
const nextConfig = {
|
|
41
|
+
transpilePackages: ['@miketromba/screenshot-service'],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = nextConfig
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Step 2: Create the API route**
|
|
48
|
+
|
|
49
|
+
Next.js App Router (`app/api/screenshot/route.ts`):
|
|
36
50
|
```ts
|
|
37
51
|
export { GET } from '@miketromba/screenshot-service/vercel'
|
|
38
52
|
```
|
|
39
53
|
|
|
40
|
-
|
|
54
|
+
Or for Pages Router (`pages/api/screenshot.ts`):
|
|
41
55
|
```ts
|
|
42
56
|
export { GET } from '@miketromba/screenshot-service/vercel'
|
|
43
57
|
```
|
|
44
58
|
|
|
45
|
-
Add function configuration
|
|
59
|
+
**Step 3: Add function configuration** (`vercel.json`):
|
|
46
60
|
```json
|
|
47
61
|
{
|
|
48
62
|
"functions": {
|
|
@@ -205,7 +219,8 @@ The service provides several options to control when the screenshot is taken, en
|
|
|
205
219
|
- Input validation using [Zod](https://zod.dev/)
|
|
206
220
|
- Requires [Bun](https://bun.sh) runtime
|
|
207
221
|
|
|
208
|
-
### Vercel Serverless
|
|
222
|
+
### Vercel Serverless (Next.js)
|
|
223
|
+
- Requires Next.js with `transpilePackages` configured (ships raw TypeScript)
|
|
209
224
|
- Serverless function with [puppeteer-core](https://pptr.dev/)
|
|
210
225
|
- Uses [@sparticuz/chromium](https://github.com/Sparticuz/chromium) for serverless-optimized Chromium
|
|
211
226
|
- Shared validation and screenshot logic
|
|
@@ -277,15 +292,16 @@ Note: The `--add-host` flag is required for `host.docker.internal` to work. Make
|
|
|
277
292
|
- **No custom fonts**: Unlike local development, Vercel functions don't include custom fonts. Screenshots may render with different fonts.
|
|
278
293
|
- **Scaling**: Vercel handles scaling automatically via parallel function invocations
|
|
279
294
|
|
|
280
|
-
### Local Dev vs Vercel
|
|
295
|
+
### Local Dev vs Vercel + Next.js
|
|
281
296
|
|
|
282
|
-
| Feature | Local (npx) | Vercel |
|
|
283
|
-
|
|
297
|
+
| Feature | Local (npx) | Vercel + Next.js |
|
|
298
|
+
|---------|-------------|------------------|
|
|
284
299
|
| Concurrency | puppeteer-cluster (configurable) | Horizontal scaling |
|
|
285
300
|
| Cold start | None (always running) | 5-10 seconds |
|
|
286
301
|
| Custom fonts | System fonts available | Limited |
|
|
287
302
|
| Max timeout | Unlimited | 60-300 seconds |
|
|
288
303
|
| Cost | Free (local) | Pay per invocation |
|
|
304
|
+
| Setup | None | Requires `transpilePackages` config |
|
|
289
305
|
|
|
290
306
|
## Example Usage
|
|
291
307
|
|
package/bin/cli.ts
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miketromba/screenshot-service",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"screenshot-service": "bin/cli.ts"
|
|
7
7
|
},
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"import": "./
|
|
11
|
-
"types": "./
|
|
10
|
+
"import": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts"
|
|
12
12
|
},
|
|
13
13
|
"./vercel": {
|
|
14
|
-
"import": "./
|
|
15
|
-
"types": "./
|
|
14
|
+
"import": "./src/vercel.ts",
|
|
15
|
+
"types": "./src/vercel.ts"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"dist",
|
|
20
19
|
"bin",
|
|
21
20
|
"src"
|
|
22
21
|
],
|
|
23
22
|
"scripts": {
|
|
24
|
-
"build": "tsc",
|
|
25
|
-
"prepublishOnly": "npm run build",
|
|
26
23
|
"dev": "bun --watch src/server.ts",
|
|
27
24
|
"start": "bun run src/server.ts",
|
|
28
25
|
"docker:build": "docker build -t screenshot-service ."
|
|
29
26
|
},
|
|
30
27
|
"dependencies": {
|
|
31
28
|
"@hono/zod-validator": "^0.4.3",
|
|
32
|
-
"@sparticuz/chromium
|
|
29
|
+
"@sparticuz/chromium": "^143.0.4",
|
|
33
30
|
"hono": "^4.7.4",
|
|
34
31
|
"puppeteer-core": "^24.36.0",
|
|
35
32
|
"zod": "^3.24.2"
|
|
@@ -39,8 +36,7 @@
|
|
|
39
36
|
"puppeteer-cluster": "^0.24.0"
|
|
40
37
|
},
|
|
41
38
|
"devDependencies": {
|
|
42
|
-
"@types/bun": "latest"
|
|
43
|
-
"typescript": "^5.0.0"
|
|
39
|
+
"@types/bun": "latest"
|
|
44
40
|
},
|
|
45
41
|
"trustedDependencies": [
|
|
46
42
|
"puppeteer"
|
package/src/vercel.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { Browser, Page } from 'puppeteer-core'
|
|
13
13
|
import puppeteer from 'puppeteer-core'
|
|
14
|
-
import chromium from '@sparticuz/chromium
|
|
14
|
+
import chromium from '@sparticuz/chromium'
|
|
15
15
|
import {
|
|
16
16
|
screenshotQuerySchema,
|
|
17
17
|
isUrlHostnameAllowed,
|
|
@@ -23,11 +23,6 @@ import {
|
|
|
23
23
|
CHROME_ARGS
|
|
24
24
|
} from './shared'
|
|
25
25
|
|
|
26
|
-
// Remote chromium URL - uses @sparticuz/chromium releases on GitHub
|
|
27
|
-
// This avoids bundling issues on Vercel since chromium is downloaded at runtime
|
|
28
|
-
const CHROMIUM_REMOTE_URL =
|
|
29
|
-
'https://github.com/Sparticuz/chromium/releases/download/v133.0.0/chromium-v133.0.0-pack.tar'
|
|
30
|
-
|
|
31
26
|
// Cache browser instance for warm invocations
|
|
32
27
|
let browser: Browser | null = null
|
|
33
28
|
|
|
@@ -39,7 +34,7 @@ async function getBrowser(): Promise<Browser> {
|
|
|
39
34
|
browser = await puppeteer.launch({
|
|
40
35
|
args: [...chromium.args, ...CHROME_ARGS],
|
|
41
36
|
defaultViewport: null,
|
|
42
|
-
executablePath: await chromium.executablePath(
|
|
37
|
+
executablePath: await chromium.executablePath(),
|
|
43
38
|
headless: true
|
|
44
39
|
})
|
|
45
40
|
|
package/dist/index.d.ts
DELETED
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
export { screenshotQuerySchema, type ScreenshotQuery, type ScreenshotOptions, type AuthResult, type Page, getHostWhitelist, getAuthToken, isUrlHostnameAllowed, queryToScreenshotOptions, validateBearerToken, captureScreenshot, CHROME_ARGS, USER_AGENT, SCREENSHOT_CSS } from './shared';
|
|
2
|
-
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAEN,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,UAAU,EACf,KAAK,IAAI,EAGT,gBAAgB,EAChB,YAAY,EACZ,oBAAoB,EACpB,wBAAwB,EACxB,mBAAmB,EACnB,iBAAiB,EAGjB,WAAW,EACX,UAAU,EACV,cAAc,EACd,MAAM,UAAU,CAAA"}
|
package/dist/index.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// Main package exports - shared utilities, types, and schemas
|
|
2
|
-
export {
|
|
3
|
-
// Schema and types
|
|
4
|
-
screenshotQuerySchema,
|
|
5
|
-
// Utilities
|
|
6
|
-
getHostWhitelist, getAuthToken, isUrlHostnameAllowed, queryToScreenshotOptions, validateBearerToken, captureScreenshot,
|
|
7
|
-
// Constants
|
|
8
|
-
CHROME_ARGS, USER_AGENT, SCREENSHOT_CSS } from './shared';
|
package/dist/server.d.ts
DELETED
package/dist/server.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":""}
|
package/dist/server.js
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
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 { screenshotQuerySchema, isUrlHostnameAllowed, queryToScreenshotOptions, validateBearerToken, captureScreenshot, getHostWhitelist, getAuthToken, CHROME_ARGS } from './shared';
|
|
8
|
-
const MAX_CONCURRENCY = process.env.MAX_CONCURRENCY
|
|
9
|
-
? parseInt(process.env.MAX_CONCURRENCY)
|
|
10
|
-
: 10;
|
|
11
|
-
const SCREENSHOT_HOST_WHITELIST = getHostWhitelist();
|
|
12
|
-
const SCREENSHOT_AUTH_TOKEN = getAuthToken();
|
|
13
|
-
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
|
14
|
-
const DEV_MODE = process.env.NODE_ENV === 'development';
|
|
15
|
-
let cluster;
|
|
16
|
-
async function getCluster() {
|
|
17
|
-
if (!cluster) {
|
|
18
|
-
cluster = await Cluster.launch({
|
|
19
|
-
concurrency: Cluster.CONCURRENCY_CONTEXT,
|
|
20
|
-
maxConcurrency: MAX_CONCURRENCY,
|
|
21
|
-
puppeteerOptions: {
|
|
22
|
-
timeout: 300_000, // 5 minutes -- give it very generous timeout since loading browsers all at once on resource-bound VM is slow
|
|
23
|
-
headless: true,
|
|
24
|
-
args: CHROME_ARGS
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
return cluster;
|
|
29
|
-
}
|
|
30
|
-
async function takeScreenshot(opts) {
|
|
31
|
-
const cluster = await getCluster();
|
|
32
|
-
return cluster.execute(null, async ({ page }) => {
|
|
33
|
-
return captureScreenshot(page, opts, SCREENSHOT_AUTH_TOKEN);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
const app = new Hono();
|
|
37
|
-
// Add common middleware
|
|
38
|
-
// Only use logger middleware in development environment
|
|
39
|
-
if (DEV_MODE) {
|
|
40
|
-
app.use('*', logger());
|
|
41
|
-
}
|
|
42
|
-
app.use('*', cors());
|
|
43
|
-
// Authentication middleware
|
|
44
|
-
const auth = async (c, next) => {
|
|
45
|
-
// Skip auth for health check endpoint
|
|
46
|
-
if (c.req.path === '/') {
|
|
47
|
-
return next();
|
|
48
|
-
}
|
|
49
|
-
const authResult = validateBearerToken(c.req.header('Authorization'), SCREENSHOT_AUTH_TOKEN);
|
|
50
|
-
if (authResult.valid === false) {
|
|
51
|
-
return c.json({ error: authResult.error }, authResult.status);
|
|
52
|
-
}
|
|
53
|
-
return next();
|
|
54
|
-
};
|
|
55
|
-
if (SCREENSHOT_AUTH_TOKEN) {
|
|
56
|
-
app.use('*', auth);
|
|
57
|
-
}
|
|
58
|
-
app.get('/', c => {
|
|
59
|
-
return c.json({ online: true });
|
|
60
|
-
});
|
|
61
|
-
app.get('/screenshot', zValidator('query', screenshotQuerySchema), async (c) => {
|
|
62
|
-
const query = c.req.valid('query');
|
|
63
|
-
// Prod logging
|
|
64
|
-
if (!DEV_MODE)
|
|
65
|
-
console.log('CAPTURE:', query.url);
|
|
66
|
-
// Check if the URL's hostname is allowed
|
|
67
|
-
if (!isUrlHostnameAllowed(query.url, SCREENSHOT_HOST_WHITELIST)) {
|
|
68
|
-
return c.json({
|
|
69
|
-
error: `Hostname not allowed. Must be one of: ${SCREENSHOT_HOST_WHITELIST.join(', ')}`
|
|
70
|
-
}, 403);
|
|
71
|
-
}
|
|
72
|
-
const screenshot = await takeScreenshot(queryToScreenshotOptions(query));
|
|
73
|
-
return new Response(Buffer.from(screenshot), {
|
|
74
|
-
headers: {
|
|
75
|
-
'Content-Type': `image/${query.type}`
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
Bun.serve({
|
|
80
|
-
port: PORT,
|
|
81
|
-
fetch: app.fetch,
|
|
82
|
-
idleTimeout: 255 // max value, 5 mins
|
|
83
|
-
});
|
|
84
|
-
console.log(`Screenshot service is online on port ${PORT}`);
|
package/dist/shared.d.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import type { Page as PuppeteerPage } from 'puppeteer';
|
|
3
|
-
import type { Page as PuppeteerCorePage } from 'puppeteer-core';
|
|
4
|
-
export declare const getHostWhitelist: () => string[];
|
|
5
|
-
export declare const getAuthToken: () => string | undefined;
|
|
6
|
-
export declare const screenshotQuerySchema: z.ZodObject<{
|
|
7
|
-
url: z.ZodString;
|
|
8
|
-
fullPage: z.ZodDefault<z.ZodOptional<z.ZodEnum<["true", "false"]>>>;
|
|
9
|
-
quality: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
10
|
-
type: z.ZodDefault<z.ZodOptional<z.ZodEnum<["png", "webp", "jpeg"]>>>;
|
|
11
|
-
width: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
12
|
-
height: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
13
|
-
waitUntil: z.ZodDefault<z.ZodOptional<z.ZodEnum<["load", "domcontentloaded", "networkidle0", "networkidle2"]>>>;
|
|
14
|
-
waitForSelector: z.ZodOptional<z.ZodString>;
|
|
15
|
-
delay: z.ZodOptional<z.ZodNumber>;
|
|
16
|
-
}, "strip", z.ZodTypeAny, {
|
|
17
|
-
url: string;
|
|
18
|
-
fullPage: "true" | "false";
|
|
19
|
-
quality: number;
|
|
20
|
-
type: "png" | "webp" | "jpeg";
|
|
21
|
-
width: number;
|
|
22
|
-
height: number;
|
|
23
|
-
waitUntil: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
|
|
24
|
-
waitForSelector?: string | undefined;
|
|
25
|
-
delay?: number | undefined;
|
|
26
|
-
}, {
|
|
27
|
-
url: string;
|
|
28
|
-
fullPage?: "true" | "false" | undefined;
|
|
29
|
-
quality?: number | undefined;
|
|
30
|
-
type?: "png" | "webp" | "jpeg" | undefined;
|
|
31
|
-
width?: number | undefined;
|
|
32
|
-
height?: number | undefined;
|
|
33
|
-
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" | undefined;
|
|
34
|
-
waitForSelector?: string | undefined;
|
|
35
|
-
delay?: number | undefined;
|
|
36
|
-
}>;
|
|
37
|
-
export type ScreenshotQuery = z.infer<typeof screenshotQuerySchema>;
|
|
38
|
-
export type ScreenshotOptions = {
|
|
39
|
-
url: string;
|
|
40
|
-
fullPage: boolean;
|
|
41
|
-
quality: number;
|
|
42
|
-
type: 'png' | 'webp' | 'jpeg';
|
|
43
|
-
dimensions: {
|
|
44
|
-
width: number;
|
|
45
|
-
height: number;
|
|
46
|
-
};
|
|
47
|
-
waitUntil: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
|
|
48
|
-
waitForSelector?: string;
|
|
49
|
-
delay?: number;
|
|
50
|
-
};
|
|
51
|
-
export declare function isUrlHostnameAllowed(url: string, whitelist: string[]): boolean;
|
|
52
|
-
export declare function queryToScreenshotOptions(query: ScreenshotQuery): ScreenshotOptions;
|
|
53
|
-
export declare const CHROME_ARGS: string[];
|
|
54
|
-
export declare const USER_AGENT = "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";
|
|
55
|
-
export declare const SCREENSHOT_CSS = "\n\t* {\n\t\t-webkit-print-color-adjust: exact !important;\n\t\ttext-rendering: geometricprecision !important;\n\t\t-webkit-font-smoothing: antialiased !important;\n\t}\n";
|
|
56
|
-
export type Page = PuppeteerPage | PuppeteerCorePage;
|
|
57
|
-
export declare function captureScreenshot(page: Page, opts: ScreenshotOptions, authToken?: string): Promise<Uint8Array>;
|
|
58
|
-
export type AuthResult = {
|
|
59
|
-
valid: true;
|
|
60
|
-
} | {
|
|
61
|
-
valid: false;
|
|
62
|
-
error: string;
|
|
63
|
-
status: 401 | 403;
|
|
64
|
-
};
|
|
65
|
-
export declare function validateBearerToken(authHeader: string | null | undefined, expectedToken: string): AuthResult;
|
|
66
|
-
//# sourceMappingURL=shared.d.ts.map
|
package/dist/shared.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,IAAI,IAAI,aAAa,EAAE,MAAM,WAAW,CAAA;AACtD,OAAO,KAAK,EAAE,IAAI,IAAI,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAG/D,eAAO,MAAM,gBAAgB,gBAC8C,CAAA;AAE3E,eAAO,MAAM,YAAY,0BAA0C,CAAA;AAGnE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAahC,CAAA;AAEF,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAA;AAGnE,MAAM,MAAM,iBAAiB,GAAG;IAC/B,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,OAAO,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAAA;IAC7B,UAAU,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7C,SAAS,EAAE,MAAM,GAAG,kBAAkB,GAAG,cAAc,GAAG,cAAc,CAAA;IACxE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAGD,wBAAgB,oBAAoB,CACnC,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EAAE,GACjB,OAAO,CAYT;AAGD,wBAAgB,wBAAwB,CACvC,KAAK,EAAE,eAAe,GACpB,iBAAiB,CAcnB;AAGD,eAAO,MAAM,WAAW,UAMvB,CAAA;AAGD,eAAO,MAAM,UAAU,0HACiG,CAAA;AAGxH,eAAO,MAAM,cAAc,+KAM1B,CAAA;AAGD,MAAM,MAAM,IAAI,GAAG,aAAa,GAAG,iBAAiB,CAAA;AAIpD,wBAAsB,iBAAiB,CACtC,IAAI,EAAE,IAAI,EACV,IAAI,EAAE,iBAAiB,EACvB,SAAS,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAiDrB;AAGD,MAAM,MAAM,UAAU,GACnB;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,GACf;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAA;CAAE,CAAA;AAGrD,wBAAgB,mBAAmB,CAClC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACrC,aAAa,EAAE,MAAM,GACnB,UAAU,CAaZ"}
|
package/dist/shared.js
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
// Environment variables
|
|
3
|
-
export const getHostWhitelist = () => process.env.SCREENSHOT_HOST_WHITELIST?.split(',').map(h => h.trim()) || [];
|
|
4
|
-
export const getAuthToken = () => process.env.SCREENSHOT_AUTH_TOKEN;
|
|
5
|
-
// Query parameter schema for screenshot endpoint
|
|
6
|
-
export const screenshotQuerySchema = z.object({
|
|
7
|
-
url: z.string().url(),
|
|
8
|
-
fullPage: z.enum(['true', 'false']).optional().default('false'),
|
|
9
|
-
quality: z.coerce.number().min(1).max(100).optional().default(100),
|
|
10
|
-
type: z.enum(['png', 'webp', 'jpeg']).optional().default('png'),
|
|
11
|
-
width: z.coerce.number().min(1).max(1920).optional().default(1440),
|
|
12
|
-
height: z.coerce.number().min(1).max(10000).optional().default(900),
|
|
13
|
-
waitUntil: z
|
|
14
|
-
.enum(['load', 'domcontentloaded', 'networkidle0', 'networkidle2'])
|
|
15
|
-
.optional()
|
|
16
|
-
.default('networkidle2'),
|
|
17
|
-
waitForSelector: z.string().optional(),
|
|
18
|
-
delay: z.coerce.number().min(0).max(30000).optional()
|
|
19
|
-
});
|
|
20
|
-
// Check if URL hostname is in the whitelist
|
|
21
|
-
export function isUrlHostnameAllowed(url, whitelist) {
|
|
22
|
-
try {
|
|
23
|
-
const hostname = new URL(url).hostname;
|
|
24
|
-
if (!whitelist.length) {
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
return whitelist.some(allowed => hostname === allowed || hostname.endsWith(`.${allowed}`));
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
// Convert validated query params to screenshot options
|
|
34
|
-
export function queryToScreenshotOptions(query) {
|
|
35
|
-
return {
|
|
36
|
-
url: query.url,
|
|
37
|
-
fullPage: query.fullPage === 'true',
|
|
38
|
-
quality: query.quality,
|
|
39
|
-
type: query.type,
|
|
40
|
-
dimensions: {
|
|
41
|
-
width: query.width,
|
|
42
|
-
height: query.height
|
|
43
|
-
},
|
|
44
|
-
waitUntil: query.waitUntil,
|
|
45
|
-
waitForSelector: query.waitForSelector,
|
|
46
|
-
delay: query.delay
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
// Common Chrome launch arguments for consistent rendering
|
|
50
|
-
export const CHROME_ARGS = [
|
|
51
|
-
'--no-sandbox',
|
|
52
|
-
'--disable-setuid-sandbox',
|
|
53
|
-
'--font-render-hinting=none',
|
|
54
|
-
'--disable-font-subpixel-positioning',
|
|
55
|
-
'--force-color-profile=srgb'
|
|
56
|
-
];
|
|
57
|
-
// User agent string
|
|
58
|
-
export const USER_AGENT = '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';
|
|
59
|
-
// CSS for consistent font rendering
|
|
60
|
-
export const SCREENSHOT_CSS = `
|
|
61
|
-
* {
|
|
62
|
-
-webkit-print-color-adjust: exact !important;
|
|
63
|
-
text-rendering: geometricprecision !important;
|
|
64
|
-
-webkit-font-smoothing: antialiased !important;
|
|
65
|
-
}
|
|
66
|
-
`;
|
|
67
|
-
// Capture screenshot using a page instance (shared logic for both implementations)
|
|
68
|
-
// Uses PuppeteerCorePage internally since both packages have identical runtime APIs
|
|
69
|
-
export async function captureScreenshot(page, opts, authToken) {
|
|
70
|
-
// Cast to PuppeteerCorePage to avoid union type signature conflicts
|
|
71
|
-
// Both puppeteer and puppeteer-core have identical APIs at runtime
|
|
72
|
-
const p = page;
|
|
73
|
-
// Use string format for compatibility with both puppeteer and puppeteer-core
|
|
74
|
-
await p.setUserAgent(USER_AGENT);
|
|
75
|
-
// Set authorization header for all requests
|
|
76
|
-
if (authToken) {
|
|
77
|
-
await p.setExtraHTTPHeaders({
|
|
78
|
-
Authorization: `Bearer ${authToken}`
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
await p.setViewport({
|
|
82
|
-
width: opts.dimensions.width,
|
|
83
|
-
height: opts.dimensions.height
|
|
84
|
-
});
|
|
85
|
-
await p.goto(opts.url, {
|
|
86
|
-
waitUntil: opts.waitUntil
|
|
87
|
-
});
|
|
88
|
-
// Set custom CSS to ensure consistent font rendering
|
|
89
|
-
await p.addStyleTag({
|
|
90
|
-
content: SCREENSHOT_CSS
|
|
91
|
-
});
|
|
92
|
-
// Wait for specific selector if provided
|
|
93
|
-
if (opts.waitForSelector) {
|
|
94
|
-
await p.waitForSelector(opts.waitForSelector, { timeout: 30000 });
|
|
95
|
-
}
|
|
96
|
-
// Wait for fonts to load
|
|
97
|
-
await p.evaluateHandle('document.fonts.ready');
|
|
98
|
-
// Additional delay if specified
|
|
99
|
-
if (opts.delay) {
|
|
100
|
-
await new Promise(resolve => setTimeout(resolve, opts.delay));
|
|
101
|
-
}
|
|
102
|
-
const screenshot = await p.screenshot({
|
|
103
|
-
type: opts.type,
|
|
104
|
-
...(opts.type !== 'png' ? { quality: opts.quality } : {}),
|
|
105
|
-
fullPage: opts.fullPage
|
|
106
|
-
});
|
|
107
|
-
return screenshot;
|
|
108
|
-
}
|
|
109
|
-
// Validate Bearer token from Authorization header
|
|
110
|
-
export function validateBearerToken(authHeader, expectedToken) {
|
|
111
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
112
|
-
return {
|
|
113
|
-
valid: false,
|
|
114
|
-
error: 'Authorization header missing or invalid format',
|
|
115
|
-
status: 401
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
const token = authHeader.split(' ')[1];
|
|
119
|
-
if (token !== expectedToken) {
|
|
120
|
-
return { valid: false, error: 'Invalid token', status: 403 };
|
|
121
|
-
}
|
|
122
|
-
return { valid: true };
|
|
123
|
-
}
|
package/dist/vercel.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
* GET handler for Vercel serverless functions.
|
|
13
|
-
* Re-export this from your API route.
|
|
14
|
-
*/
|
|
15
|
-
export declare function GET(request: Request): Promise<Response>;
|
|
16
|
-
//# sourceMappingURL=vercel.d.ts.map
|
package/dist/vercel.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"vercel.d.ts","sourceRoot":"","sources":["../src/vercel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAqDH;;;GAGG;AACH,wBAAsB,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAkE7D"}
|
package/dist/vercel.js
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
import puppeteer from 'puppeteer-core';
|
|
12
|
-
import chromium from '@sparticuz/chromium-min';
|
|
13
|
-
import { screenshotQuerySchema, isUrlHostnameAllowed, queryToScreenshotOptions, validateBearerToken, captureScreenshot, getHostWhitelist, getAuthToken, CHROME_ARGS } from './shared';
|
|
14
|
-
// Remote chromium URL - uses @sparticuz/chromium releases on GitHub
|
|
15
|
-
// This avoids bundling issues on Vercel since chromium is downloaded at runtime
|
|
16
|
-
const CHROMIUM_REMOTE_URL = 'https://github.com/Sparticuz/chromium/releases/download/v133.0.0/chromium-v133.0.0-pack.tar';
|
|
17
|
-
// Cache browser instance for warm invocations
|
|
18
|
-
let browser = null;
|
|
19
|
-
async function getBrowser() {
|
|
20
|
-
if (browser) {
|
|
21
|
-
return browser;
|
|
22
|
-
}
|
|
23
|
-
browser = await puppeteer.launch({
|
|
24
|
-
args: [...chromium.args, ...CHROME_ARGS],
|
|
25
|
-
defaultViewport: null,
|
|
26
|
-
executablePath: await chromium.executablePath(CHROMIUM_REMOTE_URL),
|
|
27
|
-
headless: true
|
|
28
|
-
});
|
|
29
|
-
return browser;
|
|
30
|
-
}
|
|
31
|
-
async function takeScreenshot(opts, authToken) {
|
|
32
|
-
const browser = await getBrowser();
|
|
33
|
-
const page = await browser.newPage();
|
|
34
|
-
try {
|
|
35
|
-
return await captureScreenshot(page, opts, authToken);
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
await page.close();
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* GET handler for Vercel serverless functions.
|
|
43
|
-
* Re-export this from your API route.
|
|
44
|
-
*/
|
|
45
|
-
export async function GET(request) {
|
|
46
|
-
const url = new URL(request.url);
|
|
47
|
-
const params = Object.fromEntries(url.searchParams.entries());
|
|
48
|
-
// Validate query parameters
|
|
49
|
-
const result = screenshotQuerySchema.safeParse(params);
|
|
50
|
-
if (!result.success) {
|
|
51
|
-
return Response.json({
|
|
52
|
-
error: 'Invalid query parameters',
|
|
53
|
-
details: result.error.flatten()
|
|
54
|
-
}, { status: 400 });
|
|
55
|
-
}
|
|
56
|
-
const query = result.data;
|
|
57
|
-
const authToken = getAuthToken();
|
|
58
|
-
const hostWhitelist = getHostWhitelist();
|
|
59
|
-
// Check authentication if SCREENSHOT_AUTH_TOKEN is set
|
|
60
|
-
if (authToken) {
|
|
61
|
-
const authResult = validateBearerToken(request.headers.get('Authorization'), authToken);
|
|
62
|
-
if (authResult.valid === false) {
|
|
63
|
-
return Response.json({ error: authResult.error }, { status: authResult.status });
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
// Check if the URL's hostname is allowed
|
|
67
|
-
if (!isUrlHostnameAllowed(query.url, hostWhitelist)) {
|
|
68
|
-
return Response.json({
|
|
69
|
-
error: `Hostname not allowed. Must be one of: ${hostWhitelist.join(', ')}`
|
|
70
|
-
}, { status: 403 });
|
|
71
|
-
}
|
|
72
|
-
console.log('CAPTURE:', query.url);
|
|
73
|
-
try {
|
|
74
|
-
const opts = queryToScreenshotOptions(query);
|
|
75
|
-
const screenshot = await takeScreenshot(opts, authToken);
|
|
76
|
-
return new Response(Buffer.from(screenshot), {
|
|
77
|
-
headers: {
|
|
78
|
-
'Content-Type': `image/${query.type}`
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
console.error('Screenshot error:', error);
|
|
84
|
-
return Response.json({
|
|
85
|
-
error: 'Failed to capture screenshot',
|
|
86
|
-
message: error instanceof Error ? error.message : 'Unknown error'
|
|
87
|
-
}, { status: 500 });
|
|
88
|
-
}
|
|
89
|
-
}
|