@sparticuz/chromium
Version:
Chromium Binary for Serverless Platforms
236 lines (235 loc) • 8.94 kB
JavaScript
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 };