@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 CHANGED
File without changes
@@ -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';
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=server.d.ts.map
@@ -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}`);
@@ -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
+ }
@@ -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.0",
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": "./src/index.ts",
11
- "types": "./src/index.ts"
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
12
  },
13
13
  "./vercel": {
14
- "import": "./src/vercel.ts",
15
- "types": "./src/vercel.ts"
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"