1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 14:24:26 -04:00

Integrate @sparticuz/chromium and puppeteer-core for screenshot service, updating configuration and removing puppeteer dependency

This commit is contained in:
2025-10-05 17:44:56 -04:00
parent 52089cbefa
commit 29fd6f5e32
7 changed files with 33 additions and 210 deletions

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
public-hoist-pattern[]=@sparticuz/chromium
public-hoist-pattern[]=puppeteer-core

17
lib/puppeteer.ts Normal file
View File

@@ -0,0 +1,17 @@
import "server-only";
export async function launchChromium(
overrides: Record<string, unknown> = {},
): Promise<import("puppeteer-core").Browser> {
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,
});
}

View File

@@ -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,

View File

@@ -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",

123
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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),

View File

@@ -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<string, unknown>,
) => Promise<import("puppeteer-core").Browser>;
let puppeteerLaunch: LaunchFn = async () => {
throw new Error("puppeteer launcher not configured");
};
let launchOptions: Record<string, unknown> = { 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) {