UNPKG

@sparticuz/chromium

Version:

Chromium Binary for Serverless Platforms

236 lines (235 loc) 8.94 kB
import { createReadStream, createWriteStream, existsSync, rm } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { extract } from "tar-fs"; import { createBrotliDecompress, createUnzip } from "node:zlib"; import { fileURLToPath } from "node:url"; //#region source/helper.ts /** * Adds the proper folders to the environment * @param baseLibPath the path to this packages lib folder */ const setupLambdaEnvironment = (baseLibPath) => { process.env["FONTCONFIG_PATH"] ??= join(tmpdir(), "fonts"); process.env["HOME"] ??= tmpdir(); if (process.env["LD_LIBRARY_PATH"] === void 0) process.env["LD_LIBRARY_PATH"] = baseLibPath; else if (!process.env["LD_LIBRARY_PATH"].startsWith(baseLibPath)) process.env["LD_LIBRARY_PATH"] = [baseLibPath, ...new Set(process.env["LD_LIBRARY_PATH"].split(":"))].join(":"); }; /** * Determines if the input is a valid URL * @param input the input to check * @returns boolean indicating if the input is a valid URL */ const isValidUrl = (input) => { try { const url = new URL(input); if (url.protocol === "https:") return true; if (url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]")) return true; return false; } catch { return false; } }; /** * Determines if the running instance is inside an Amazon Linux 2023 container, * AWS_EXECUTION_ENV is for native Lambda instances * AWS_LAMBDA_JS_RUNTIME is for netlify instances * CODEBUILD_BUILD_IMAGE is for CodeBuild instances * VERCEL is for Vercel Functions (Node 20 or later enables an AL2023-compatible environment). * @returns boolean indicating if the running instance is inside a Lambda container with nodejs20 */ const isRunningInAmazonLinux2023 = (nodeMajorVersion) => { const awsExecEnv = process.env["AWS_EXECUTION_ENV"] ?? ""; const awsLambdaJsRuntime = process.env["AWS_LAMBDA_JS_RUNTIME"] ?? ""; const codebuildImage = process.env["CODEBUILD_BUILD_IMAGE"] ?? ""; if (awsExecEnv.includes("20.x") || awsExecEnv.includes("22.x") || awsExecEnv.includes("24.x") || awsLambdaJsRuntime.includes("20.x") || awsLambdaJsRuntime.includes("22.x") || awsLambdaJsRuntime.includes("24.x") || codebuildImage.includes("nodejs20") || codebuildImage.includes("nodejs22") || codebuildImage.includes("nodejs24")) return true; if (process.env["VERCEL"] && nodeMajorVersion >= 20) return true; return false; }; const downloadAndExtract = async (url) => { const destDir = join(tmpdir(), "chromium-pack"); const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(3e5) }); if (!response.ok) throw new Error(`Unexpected status code: ${String(response.status)}.`); if (!response.body) throw new Error("Response body is empty."); try { await pipeline(Readable.fromWeb(response.body), extract(destDir)); } catch (error) { await new Promise((resolve) => { rm(destDir, { force: true, recursive: true }, () => { resolve(); }); }); throw error; } return destDir; }; //#endregion //#region source/lambdafs.ts /** * Decompresses a (tarballed) Brotli or Gzip compressed file and returns the path to the decompressed file/folder. * * @param filePath Path of the file to decompress. */ const inflate = (filePath) => { const output = filePath.includes("swiftshader") ? tmpdir() : join(tmpdir(), basename(filePath).replace(/\.(?:t(?:ar(?:\.(?:br|gz))?|br|gz)|br|gz)$/i, "")); return new Promise((resolve, reject) => { if (filePath.includes("swiftshader")) { if (existsSync(`${output}/libGLESv2.so`)) { resolve(output); return; } } else if (existsSync(output)) { resolve(output); return; } const isBrotli = /br$/i.test(filePath); const isGzip = /gz$/i.test(filePath); const isTar = /\.t(?:ar(?:\.(?:br|gz))?|br|gz)$/i.test(filePath); const source = createReadStream(filePath, { highWaterMark: 2 ** 22 }); let target; const handleError = (error) => { reject(error); }; source.once("error", handleError); if (isTar) { target = extract(output); target.once("finish", () => { resolve(output); }); } else { target = createWriteStream(output, { mode: 448 }); target.once("close", () => { resolve(output); }); } target.once("error", handleError); if (isBrotli || isGzip) { const decompressor = isBrotli ? createBrotliDecompress({ chunkSize: 2 ** 21 }) : createUnzip({ chunkSize: 2 ** 21 }); decompressor.once("error", handleError); source.pipe(decompressor).pipe(target); } else source.pipe(target); }); }; //#endregion //#region source/paths.ts /** * Get the bin directory path for ESM modules */ function getBinPath() { return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin"); } //#endregion //#region source/index.ts const nodeMajorVersion = Number.parseInt(process.versions.node.split(".")[0] ?? ""); if (isRunningInAmazonLinux2023(nodeMajorVersion)) setupLambdaEnvironment(join(tmpdir(), "al2023", "lib")); var Chromium = class { /** * Returns a list of additional Chromium flags recommended for serverless environments. * The canonical list of flags can be found on https://peter.sh/experiments/chromium-command-line-switches/. * Most of below can be found here: https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md */ static get args() { const chromiumFlags = [ "--ash-no-nudges", "--disable-domain-reliability", "--disable-print-preview", "--disk-cache-size=33554432", "--no-default-browser-check", "--no-pings", "--single-process", "--font-render-hinting=none" ]; const chromiumDisableFeatures = [ "AudioServiceOutOfProcess", "IsolateOrigins", "site-per-process" ]; const chromiumEnableFeatures = ["SharedArrayBuffer"]; const graphicsFlags = ["--ignore-gpu-blocklist", "--in-process-gpu"]; if (this.graphics) graphicsFlags.push("--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"); else graphicsFlags.push("--disable-webgl"); const insecureFlags = [ "--allow-running-insecure-content", "--disable-setuid-sandbox", "--disable-site-isolation-trials", "--disable-web-security" ]; const headlessFlags = [ "--headless='shell'", "--no-sandbox", "--no-zygote" ]; return [ ...chromiumFlags, `--disable-features=${[...chromiumDisableFeatures].join(",")}`, `--enable-features=${[...chromiumEnableFeatures].join(",")}`, ...graphicsFlags, ...insecureFlags, ...headlessFlags ]; } /** * Returns whether the graphics stack is enabled or disabled * @returns boolean */ static get graphics() { return this.graphicsMode; } /** * Sets whether the graphics stack is enabled or disabled. * @param true means the stack is enabled. WebGL will work. * @param false means that the stack is disabled. WebGL will not work. * @default true */ static set setGraphicsMode(value) { if (typeof value !== "boolean") throw new TypeError(`Graphics mode must be a boolean, you entered '${String(value)}'`); this.graphicsMode = value; } /** * If true, the graphics stack and webgl is enabled, * If false, webgl will be disabled. * (If false, the swiftshader.tar.br file will also not extract) */ static graphicsMode = true; /** * Inflates the included version of Chromium * @param input The location of the `bin` folder * @returns The path to the `chromium` binary */ static async executablePath(input) { /** * If the `chromium` binary already exists in /tmp/chromium, return it. */ if (existsSync(join(tmpdir(), "chromium"))) return join(tmpdir(), "chromium"); /** * If input is a valid URL, download and extract the file. It will extract to /tmp/chromium-pack * and executablePath will be recursively called on that location, which will then extract * the brotli files to the correct locations */ if (input && isValidUrl(input)) return this.executablePath(await downloadAndExtract(input)); /** * If input is defined, use that as the location of the brotli files, * otherwise, the default location is ../../bin. * A custom location is needed for workflows that using custom packaging. */ input ??= getBinPath(); if (!existsSync(input)) throw new Error(`The input directory "${input}" does not exist. If you are using a bundler (esbuild, webpack, etc.), you must externalize @sparticuz/chromium so it is not relocated. See: https://github.com/Sparticuz/chromium#bundler-configuration`); const promises = [ inflate(join(input, "chromium.br")), inflate(join(input, "fonts.tar.br")), inflate(join(input, "swiftshader.tar.br")) ]; if (isRunningInAmazonLinux2023(nodeMajorVersion)) promises.push(inflate(join(input, "al2023.tar.br"))); return (await Promise.all(promises)).shift(); } }; //#endregion export { Chromium as default, inflate, setupLambdaEnvironment };