From 29fd6f5e32c67ced55eaa5562913016a8d17b4f7 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 5 Oct 2025 17:44:56 -0400 Subject: [PATCH] Integrate @sparticuz/chromium and puppeteer-core for screenshot service, updating configuration and removing puppeteer dependency --- .npmrc | 2 + lib/puppeteer.ts | 17 ++++ next.config.ts | 4 + package.json | 1 - pnpm-lock.yaml | 123 ----------------------------- server/services/screenshot.test.ts | 7 +- server/services/screenshot.ts | 89 ++------------------- 7 files changed, 33 insertions(+), 210 deletions(-) create mode 100644 .npmrc create mode 100644 lib/puppeteer.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..11fbc24 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +public-hoist-pattern[]=@sparticuz/chromium +public-hoist-pattern[]=puppeteer-core diff --git a/lib/puppeteer.ts b/lib/puppeteer.ts new file mode 100644 index 0000000..d167eac --- /dev/null +++ b/lib/puppeteer.ts @@ -0,0 +1,17 @@ +import "server-only"; + +export async function launchChromium( + overrides: Record = {}, +): Promise { + const chromium = (await import("@sparticuz/chromium")).default; + const { launch } = await import("puppeteer-core"); + + const executablePath = await chromium.executablePath(); + return launch({ + headless: true, + args: chromium.args, + executablePath, + defaultViewport: null, + ...overrides, + }); +} diff --git a/next.config.ts b/next.config.ts index 8bb23ca..7954f53 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,10 @@ const nextConfig: NextConfig = { ignoreDuringBuilds: true, }, serverExternalPackages: ["@sparticuz/chromium", "puppeteer-core"], + outputFileTracingRoot: process.cwd(), + outputFileTracingIncludes: { + "app/api/**": ["node_modules/@sparticuz/chromium/bin/**"], + }, reactCompiler: true, images: { unoptimized: true, diff --git a/package.json b/package.json index cfb06d9..1150358 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "@vitest/ui": "^3.2.4", "babel-plugin-react-compiler": "19.1.0-rc.3", "jsdom": "^27.0.0", - "puppeteer": "24.22.3", "tailwindcss": "^4.1.14", "tw-animate-css": "^1.4.0", "typescript": "5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a398b8a..825991b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,9 +174,6 @@ importers: jsdom: specifier: ^27.0.0 version: 27.0.0(postcss@8.5.6) - puppeteer: - specifier: 24.22.3 - version: 24.22.3(typescript@5.9.3) tailwindcss: specifier: ^4.1.14 version: 4.1.14 @@ -2033,9 +2030,6 @@ packages: any-base@1.1.0: resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2173,10 +2167,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - caniuse-lite@1.0.30001748: resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==} @@ -2249,15 +2239,6 @@ packages: core-js@3.45.1: resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2378,13 +2359,6 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2647,10 +2621,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -2663,9 +2633,6 @@ packages: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-buffer@2.0.5: resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} engines: {node: '>=4'} @@ -2746,10 +2713,6 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - jsdom@27.0.0: resolution: {integrity: sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==} engines: {node: '>=20'} @@ -2764,9 +2727,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-stringify-pretty-compact@3.0.0: resolution: {integrity: sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==} @@ -2842,9 +2802,6 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3020,14 +2977,6 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -3149,11 +3098,6 @@ packages: resolution: {integrity: sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==} engines: {node: '>=18'} - puppeteer@24.22.3: - resolution: {integrity: sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ==} - engines: {node: '>=18'} - hasBin: true - quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -3256,10 +3200,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -5572,8 +5512,6 @@ snapshots: any-base@1.1.0: {} - argparse@2.0.1: {} - aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -5713,8 +5651,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - callsites@3.1.0: {} - caniuse-lite@1.0.30001748: {} chai@5.3.3: @@ -5788,15 +5724,6 @@ snapshots: core-js@3.45.1: {} - cosmiconfig@9.0.0(typescript@5.9.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.9.3 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5906,12 +5833,6 @@ snapshots: entities@6.0.1: {} - env-paths@2.2.1: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -6193,19 +6114,12 @@ snapshots: ieee754@1.2.1: {} - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - indent-string@4.0.0: {} ip-address@10.0.1: {} ipaddr.js@2.2.0: {} - is-arrayish@0.2.1: {} - is-buffer@2.0.5: {} is-extendable@0.1.1: {} @@ -6278,10 +6192,6 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - jsdom@27.0.0(postcss@8.5.6): dependencies: '@asamuzakjp/dom-selector': 6.6.1 @@ -6312,8 +6222,6 @@ snapshots: jsesc@3.1.0: {} - json-parse-even-better-errors@2.3.1: {} - json-stringify-pretty-compact@3.0.0: {} json5@2.2.3: {} @@ -6365,8 +6273,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 - lines-and-columns@1.2.4: {} - loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -6548,17 +6454,6 @@ snapshots: package-json-from-dist@1.0.1: {} - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - parse5@7.3.0: dependencies: entities: 6.0.1 @@ -6683,22 +6578,6 @@ snapshots: - supports-color - utf-8-validate - puppeteer@24.22.3(typescript@5.9.3): - dependencies: - '@puppeteer/browsers': 2.10.10 - chromium-bidi: 9.1.0(devtools-protocol@0.0.1495869) - cosmiconfig: 9.0.0(typescript@5.9.3) - devtools-protocol: 0.0.1495869 - puppeteer-core: 24.22.3 - typed-query-selector: 2.12.0 - transitivePeerDependencies: - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - typescript - - utf-8-validate - quickselect@3.0.0: {} radix-ui@1.4.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): @@ -6838,8 +6717,6 @@ snapshots: require-from-string@2.0.2: {} - resolve-from@4.0.0: {} - resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.0 diff --git a/server/services/screenshot.test.ts b/server/services/screenshot.test.ts index 5f83b9b..e02959b 100644 --- a/server/services/screenshot.test.ts +++ b/server/services/screenshot.test.ts @@ -20,8 +20,11 @@ const browserMock = { close: vi.fn(async () => undefined), }; -vi.mock("puppeteer", () => ({ - launch: vi.fn(async () => browserMock), +vi.mock("@sparticuz/chromium", () => ({ + default: { + args: ["--no-sandbox", "--disable-setuid-sandbox"], + executablePath: vi.fn(async () => "/usr/bin/chromium"), + }, })); vi.mock("puppeteer-core", () => ({ launch: vi.fn(async () => browserMock), diff --git a/server/services/screenshot.ts b/server/services/screenshot.ts index 518f305..c72a409 100644 --- a/server/services/screenshot.ts +++ b/server/services/screenshot.ts @@ -1,7 +1,9 @@ +import type { Browser } from "puppeteer-core"; import { captureServer } from "@/lib/analytics/server"; import { getScreenshotTtlSeconds, putScreenshotBlob } from "@/lib/blob"; import { USER_AGENT } from "@/lib/constants"; import { addWatermarkToScreenshot, optimizePngCover } from "@/lib/image"; +import { launchChromium } from "@/lib/puppeteer"; import { ns, redis } from "@/lib/redis"; const VIEWPORT_WIDTH = 1200; @@ -72,91 +74,10 @@ export async function getOrCreateScreenshotBlobUrl( } // 2) Attempt to capture - let browser: import("puppeteer-core").Browser | null = null; + let browser: Browser | null = null; try { - const isVercel = process.env.VERCEL === "1"; - const isLinux = process.platform === "linux"; - const preferChromium = isLinux || isVercel; - - type LaunchFn = ( - options?: Record, - ) => Promise; - let puppeteerLaunch: LaunchFn = async () => { - throw new Error("puppeteer launcher not configured"); - }; - let launchOptions: Record = { headless: true }; - let launcherMode: "chromium" | "puppeteer" = preferChromium - ? "chromium" - : "puppeteer"; - - async function setupChromium() { - const chromium = (await import("@sparticuz/chromium")).default; - const core = await import("puppeteer-core"); - puppeteerLaunch = core.launch as unknown as LaunchFn; - launchOptions = { - ...launchOptions, - args: chromium.args, - executablePath: await chromium.executablePath(), - }; - - console.debug("[screenshot] using chromium", { - executablePath: (launchOptions as { executablePath?: unknown }) - .executablePath, - }); - } - - async function setupPuppeteer() { - const full = await import("puppeteer"); - puppeteerLaunch = (full as unknown as { launch: LaunchFn }).launch; - const path = process.env.PUPPETEER_EXECUTABLE_PATH; - launchOptions = { - ...launchOptions, - ...(path ? { executablePath: path } : {}), - args: [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - ], - }; - - console.debug("[screenshot] using puppeteer", { - executablePath: path || null, - }); - } - - // First attempt based on platform preference - try { - if (launcherMode === "chromium") await setupChromium(); - else await setupPuppeteer(); - // Try launch - - console.debug("[screenshot] launching browser", { mode: launcherMode }); - browser = await puppeteerLaunch(launchOptions); - } catch (firstErr) { - console.warn("[screenshot] first launch attempt failed", { - mode: launcherMode, - error: (firstErr as Error)?.message, - }); - // Flip mode and retry once - launcherMode = launcherMode === "chromium" ? "puppeteer" : "chromium"; - try { - if (launcherMode === "chromium") await setupChromium(); - else await setupPuppeteer(); - - console.debug("[screenshot] retry launching browser", { - mode: launcherMode, - }); - browser = await puppeteerLaunch(launchOptions); - } catch (secondErr) { - console.error("[screenshot] both launch attempts failed", { - first_error: (firstErr as Error)?.message, - second_error: (secondErr as Error)?.message, - }); - throw secondErr; - } - } - - console.debug("[screenshot] browser launched", { mode: launcherMode }); + browser = await launchChromium(); + console.debug("[screenshot] browser launched", { mode: "chromium" }); const tryUrls = buildHomepageUrls(domain); for (const url of tryUrls) {