@miketromba/screenshot-service 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +0 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +84 -0
- package/dist/shared.d.ts +66 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +123 -0
- package/dist/vercel.d.ts +16 -0
- package/dist/vercel.d.ts.map +1 -0
- package/dist/vercel.js +86 -0
- package/package.json +10 -6
package/bin/cli.ts
CHANGED
|
File without changes
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":""}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vercel.d.ts","sourceRoot":"","sources":["../src/vercel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAgDH;;;GAGG;AACH,wBAAsB,GAAG,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAkE7D"}
|
package/dist/vercel.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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';
|
|
13
|
+
import { screenshotQuerySchema, isUrlHostnameAllowed, queryToScreenshotOptions, validateBearerToken, captureScreenshot, getHostWhitelist, getAuthToken, CHROME_ARGS } from './shared';
|
|
14
|
+
// Cache browser instance for warm invocations
|
|
15
|
+
let browser = null;
|
|
16
|
+
async function getBrowser() {
|
|
17
|
+
if (browser) {
|
|
18
|
+
return browser;
|
|
19
|
+
}
|
|
20
|
+
browser = await puppeteer.launch({
|
|
21
|
+
args: [...chromium.args, ...CHROME_ARGS],
|
|
22
|
+
defaultViewport: null,
|
|
23
|
+
executablePath: await chromium.executablePath(),
|
|
24
|
+
headless: true
|
|
25
|
+
});
|
|
26
|
+
return browser;
|
|
27
|
+
}
|
|
28
|
+
async function takeScreenshot(opts, authToken) {
|
|
29
|
+
const browser = await getBrowser();
|
|
30
|
+
const page = await browser.newPage();
|
|
31
|
+
try {
|
|
32
|
+
return await captureScreenshot(page, opts, authToken);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await page.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* GET handler for Vercel serverless functions.
|
|
40
|
+
* Re-export this from your API route.
|
|
41
|
+
*/
|
|
42
|
+
export async function GET(request) {
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
const params = Object.fromEntries(url.searchParams.entries());
|
|
45
|
+
// Validate query parameters
|
|
46
|
+
const result = screenshotQuerySchema.safeParse(params);
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
return Response.json({
|
|
49
|
+
error: 'Invalid query parameters',
|
|
50
|
+
details: result.error.flatten()
|
|
51
|
+
}, { status: 400 });
|
|
52
|
+
}
|
|
53
|
+
const query = result.data;
|
|
54
|
+
const authToken = getAuthToken();
|
|
55
|
+
const hostWhitelist = getHostWhitelist();
|
|
56
|
+
// Check authentication if SCREENSHOT_AUTH_TOKEN is set
|
|
57
|
+
if (authToken) {
|
|
58
|
+
const authResult = validateBearerToken(request.headers.get('Authorization'), authToken);
|
|
59
|
+
if (authResult.valid === false) {
|
|
60
|
+
return Response.json({ error: authResult.error }, { status: authResult.status });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Check if the URL's hostname is allowed
|
|
64
|
+
if (!isUrlHostnameAllowed(query.url, hostWhitelist)) {
|
|
65
|
+
return Response.json({
|
|
66
|
+
error: `Hostname not allowed. Must be one of: ${hostWhitelist.join(', ')}`
|
|
67
|
+
}, { status: 403 });
|
|
68
|
+
}
|
|
69
|
+
console.log('CAPTURE:', query.url);
|
|
70
|
+
try {
|
|
71
|
+
const opts = queryToScreenshotOptions(query);
|
|
72
|
+
const screenshot = await takeScreenshot(opts, authToken);
|
|
73
|
+
return new Response(Buffer.from(screenshot), {
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': `image/${query.type}`
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error('Screenshot error:', error);
|
|
81
|
+
return Response.json({
|
|
82
|
+
error: 'Failed to capture screenshot',
|
|
83
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
84
|
+
}, { status: 500 });
|
|
85
|
+
}
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miketromba/screenshot-service",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
12
|
},
|
|
13
13
|
"./vercel": {
|
|
14
|
-
"import": "./
|
|
15
|
-
"types": "./
|
|
14
|
+
"import": "./dist/vercel.js",
|
|
15
|
+
"types": "./dist/vercel.d.ts"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
+
"dist",
|
|
19
20
|
"bin",
|
|
20
21
|
"src"
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"prepublishOnly": "npm run build",
|
|
23
26
|
"dev": "bun --watch src/server.ts",
|
|
24
27
|
"start": "bun run src/server.ts",
|
|
25
28
|
"docker:build": "docker build -t screenshot-service ."
|
|
@@ -36,7 +39,8 @@
|
|
|
36
39
|
"puppeteer-cluster": "^0.24.0"
|
|
37
40
|
},
|
|
38
41
|
"devDependencies": {
|
|
39
|
-
"@types/bun": "latest"
|
|
42
|
+
"@types/bun": "latest",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
40
44
|
},
|
|
41
45
|
"trustedDependencies": [
|
|
42
46
|
"puppeteer"
|