diff --git a/.gitignore b/.gitignore index 4c04346..35d65ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ *.pem.pub *.zip bin/chromium-*.br -build node_modules nodejs _/amazon/samconfig.toml @@ -12,3 +11,6 @@ _/amazon/.aws-sam *.tgz examples/**/package-lock.json examples/**/.serverless +chromium/ +_/ansible/bin +_/ansible/lib \ No newline at end of file diff --git a/_/ansible/plays/chromium.yml b/_/ansible/plays/chromium.yml index 87b979f..745361d 100644 --- a/_/ansible/plays/chromium.yml +++ b/_/ansible/plays/chromium.yml @@ -64,12 +64,12 @@ - name: Registering Host add_host: - hostname: "{{ ec2.instances[0].public_ip_address }}" + hostname: "{{ ec2.instances[0]['public_ip_address'] }}" groupname: aws - name: Waiting for SSH wait_for: - host: "{{ ec2.instances[0].public_ip_address }}" + host: "{{ ec2.instances[0]['public_ip_address'] }}" port: 22 timeout: 240 state: started @@ -251,6 +251,7 @@ disable_histogram_support = false enable_basic_print_dialog = false enable_basic_printing = true + enable_pdf = true enable_keystone_registration_framework = false enable_linux_installer = false enable_media_remoting = false diff --git a/bin/chromium.br b/bin/chromium.br old mode 100644 new mode 100755 diff --git a/bin/swiftshader.tar.br b/bin/swiftshader.tar.br index 887d34d..f75eb34 100644 Binary files a/bin/swiftshader.tar.br and b/bin/swiftshader.tar.br differ diff --git a/build/helper.d.ts b/build/helper.d.ts new file mode 100644 index 0000000..1ceb89a --- /dev/null +++ b/build/helper.d.ts @@ -0,0 +1,9 @@ +export declare const isValidUrl: (input: string) => boolean; +/** + * Determines if the running instance is inside an AWS Lambda container. + * AWS_EXECUTION_ENV is for native Lambda instances + * AWS_LAMBDA_JS_RUNTIME is for netlify instances + * @returns boolean indicating if the running instance is inside a Lambda container + */ +export declare const isRunningInAwsLambda: () => boolean; +export declare const downloadAndExtract: (url: string) => Promise; diff --git a/build/helper.js b/build/helper.js new file mode 100644 index 0000000..79ba07d --- /dev/null +++ b/build/helper.js @@ -0,0 +1,54 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.downloadAndExtract = exports.isRunningInAwsLambda = exports.isValidUrl = void 0; +const node_fs_1 = require("node:fs"); +const follow_redirects_1 = require("follow-redirects"); +const node_os_1 = require("node:os"); +const tar_fs_1 = require("tar-fs"); +const node_url_1 = require("node:url"); +const isValidUrl = (input) => { + try { + return !!new URL(input); + } + catch (err) { + return false; + } +}; +exports.isValidUrl = isValidUrl; +/** + * Determines if the running instance is inside an AWS Lambda container. + * AWS_EXECUTION_ENV is for native Lambda instances + * AWS_LAMBDA_JS_RUNTIME is for netlify instances + * @returns boolean indicating if the running instance is inside a Lambda container + */ +const isRunningInAwsLambda = () => { + if (process.env["AWS_EXECUTION_ENV"] && + /^AWS_Lambda_nodejs/.test(process.env["AWS_EXECUTION_ENV"]) === true) { + return true; + } + else if (process.env["AWS_LAMBDA_JS_RUNTIME"] && + /^nodejs/.test(process.env["AWS_LAMBDA_JS_RUNTIME"]) === true) { + return true; + } + return false; +}; +exports.isRunningInAwsLambda = isRunningInAwsLambda; +const downloadAndExtract = async (url) => new Promise((resolve, reject) => { + const getOptions = (0, node_url_1.parse)(url); + getOptions.maxBodyLength = 60 * 1024 * 1024; // 60mb + const destDir = `${(0, node_os_1.tmpdir)()}/chromium-pack`; + const extractObj = (0, tar_fs_1.extract)(destDir); + follow_redirects_1.https + .get(url, (response) => { + response.pipe(extractObj); + extractObj.on("finish", () => { + resolve(destDir); + }); + }) + .on("error", (err) => { + (0, node_fs_1.unlink)(destDir, (_) => { + reject(err); + }); + }); +}); +exports.downloadAndExtract = downloadAndExtract; diff --git a/build/index.d.ts b/build/index.d.ts new file mode 100644 index 0000000..b1c27d0 --- /dev/null +++ b/build/index.d.ts @@ -0,0 +1,95 @@ +/** Viewport taken from https://github.com/puppeteer/puppeteer/blob/main/docs/api/puppeteer.viewport.md */ +interface Viewport { + /** + * The page width in pixels. + */ + width: number; + /** + * The page height in pixels. + */ + height: number; + /** + * Specify device scale factor. + * See {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio | devicePixelRatio} for more info. + * @default 1 + */ + deviceScaleFactor?: number; + /** + * Whether the `meta viewport` tag is taken into account. + * @default false + */ + isMobile?: boolean; + /** + * Specifies if the viewport is in landscape mode. + * @default false + */ + isLandscape?: boolean; + /** + * Specify if the viewport supports touch events. + * @default false + */ + hasTouch?: boolean; +} +declare class Chromium { + /** + * Determines the headless mode that chromium will run at + * https://developer.chrome.com/articles/new-headless/#try-out-the-new-headless + * @values true or "new" + */ + private static headlessMode; + /** + * 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) + */ + private static graphicsMode; + /** + * Downloads or symlinks a custom font and returns its basename, patching the environment so that Chromium can find it. + */ + static font(input: string): Promise; + /** + * 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/. + */ + static get args(): string[]; + /** + * Returns sensible default viewport settings for serverless environments. + */ + static get defaultViewport(): Required; + /** + * Inflates the included version of Chromium + * @param input The location of the `bin` folder + * @returns The path to the `chromium` binary + */ + static executablePath(input?: string): Promise; + /** + * Returns the headless mode. + * `true` means the 'old' (legacy, chromium < 112) headless mode. + * "new" means the 'new' headless mode. + * https://developer.chrome.com/articles/new-headless/#try-out-the-new-headless + * @returns true | "new" + */ + static get headless(): true | "new"; + /** + * Sets the headless mode. + * `true` means the 'old' (legacy, chromium < 112) headless mode. + * "new" means the 'new' headless mode. + * https://developer.chrome.com/articles/new-headless/#try-out-the-new-headless + * @default "new" + */ + static set setHeadlessMode(value: true | "new"); + /** + * Returns whether the graphics stack is enabled or disabled + * @returns boolean + */ + static get graphics(): boolean; + /** + * 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. + * `false` will also skip the extract of the graphics driver, saving about a second during initial extract + * @default true + */ + static set setGraphicsMode(value: boolean); +} +export = Chromium; diff --git a/build/index.js b/build/index.js new file mode 100644 index 0000000..18387f1 --- /dev/null +++ b/build/index.js @@ -0,0 +1,299 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +const node_fs_1 = require("node:fs"); +const follow_redirects_1 = require("follow-redirects"); +const lambdafs_1 = __importDefault(require("./lambdafs")); +const node_path_1 = require("node:path"); +const node_url_1 = require("node:url"); +const helper_1 = require("./helper"); +if ((0, helper_1.isRunningInAwsLambda)()) { + if (process.env["FONTCONFIG_PATH"] === undefined) { + process.env["FONTCONFIG_PATH"] = "/tmp/aws"; + } + if (process.env["LD_LIBRARY_PATH"] === undefined) { + process.env["LD_LIBRARY_PATH"] = "/tmp/aws/lib"; + } + else if (process.env["LD_LIBRARY_PATH"].startsWith("/tmp/aws/lib") !== true) { + process.env["LD_LIBRARY_PATH"] = [ + ...new Set([ + "/tmp/aws/lib", + ...process.env["LD_LIBRARY_PATH"].split(":"), + ]), + ].join(":"); + } +} +class Chromium { + /** + * Downloads or symlinks a custom font and returns its basename, patching the environment so that Chromium can find it. + */ + static font(input) { + if (process.env["HOME"] === undefined) { + process.env["HOME"] = "/tmp"; + } + if ((0, node_fs_1.existsSync)(`${process.env["HOME"]}/.fonts`) !== true) { + (0, node_fs_1.mkdirSync)(`${process.env["HOME"]}/.fonts`); + } + return new Promise((resolve, reject) => { + if (/^https?:[/][/]/i.test(input) !== true) { + input = `file://${input}`; + } + const url = new node_url_1.URL(input); + const output = `${process.env["HOME"]}/.fonts/${url.pathname + .split("/") + .pop()}`; + if ((0, node_fs_1.existsSync)(output) === true) { + return resolve(output.split("/").pop()); + } + if (url.protocol === "file:") { + (0, node_fs_1.access)(url.pathname, (error) => { + if (error != null) { + return reject(error); + } + (0, node_fs_1.symlink)(url.pathname, output, (error) => { + return error != null + ? reject(error) + : resolve(url.pathname.split("/").pop()); + }); + }); + } + else { + follow_redirects_1.https.get(input, (response) => { + if (response.statusCode !== 200) { + return reject(`Unexpected status code: ${response.statusCode}.`); + } + const stream = (0, node_fs_1.createWriteStream)(output); + stream.once("error", (error) => { + return reject(error); + }); + response.on("data", (chunk) => { + stream.write(chunk); + }); + response.once("end", () => { + stream.end(() => { + return resolve(url.pathname.split("/").pop()); + }); + }); + }); + } + }); + } + /** + * 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/. + */ + static get args() { + /** + * These are the default args in puppeteer. + * https://github.com/puppeteer/puppeteer/blob/3a31070d054fa3cd8116ca31c578807ed8d6f987/packages/puppeteer-core/src/node/ChromeLauncher.ts#L185 + */ + const puppeteerFlags = [ + "--allow-pre-commit-input", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-breakpad", + "--disable-client-side-phishing-detection", + "--disable-component-extensions-with-background-pages", + "--disable-component-update", + "--disable-default-apps", + "--disable-dev-shm-usage", + "--disable-extensions", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-popup-blocking", + "--disable-prompt-on-repost", + "--disable-renderer-backgrounding", + "--disable-sync", + "--enable-automation", + // TODO(sadym): remove '--enable-blink-features=IdleDetection' once + // IdleDetection is turned on by default. + "--enable-blink-features=IdleDetection", + "--export-tagged-pdf", + "--force-color-profile=srgb", + "--metrics-recording-only", + "--no-first-run", + "--password-store=basic", + "--use-mock-keychain", + ]; + const puppeteerDisableFeatures = [ + "Translate", + "BackForwardCache", + // AcceptCHFrame disabled because of crbug.com/1348106. + "AcceptCHFrame", + "MediaRouter", + "OptimizationHints", + ]; + const puppeteerEnableFeatures = ["NetworkServiceInProcess2"]; + const chromiumFlags = [ + "--disable-domain-reliability", + "--disable-print-preview", + "--disable-speech-api", + "--disk-cache-size=33554432", + "--mute-audio", + "--no-default-browser-check", + "--no-pings", + "--single-process", // Needs to be single-process to avoid `prctl(PR_SET_NO_NEW_PRIVS) failed` error + ]; + const chromiumDisableFeatures = [ + "AudioServiceOutOfProcess", + "IsolateOrigins", + "site-per-process", + ]; + const chromiumEnableFeatures = ["SharedArrayBuffer"]; + const graphicsFlags = [ + "--hide-scrollbars", + "--ignore-gpu-blocklist", + "--in-process-gpu", + "--window-size=1920,1080", // https://source.chromium.org/search?q=lang:cpp+symbol:kWindowSize&ss=chromium + ]; + // https://chromium.googlesource.com/chromium/src/+/main/docs/gpu/swiftshader.md + this.graphics + ? graphicsFlags.push("--use-gl=angle", "--use-angle=swiftshader") + : graphicsFlags.push("--disable-webgl"); + const insecureFlags = [ + "--allow-running-insecure-content", + "--disable-setuid-sandbox", + "--disable-site-isolation-trials", + "--disable-web-security", + "--no-sandbox", + "--no-zygote", // https://source.chromium.org/search?q=lang:cpp+symbol:kNoZygote&ss=chromium + ]; + const headlessFlags = [ + this.headless === "new" ? "--headless='new'" : "--headless", + ]; + return [ + ...puppeteerFlags, + ...chromiumFlags, + `--disable-features=${[ + ...puppeteerDisableFeatures, + ...chromiumDisableFeatures, + ].join(",")}`, + `--enable-features=${[ + ...puppeteerEnableFeatures, + ...chromiumEnableFeatures, + ].join(",")}`, + ...graphicsFlags, + ...insecureFlags, + ...headlessFlags, + ]; + } + /** + * Returns sensible default viewport settings for serverless environments. + */ + static get defaultViewport() { + return { + deviceScaleFactor: 1, + hasTouch: false, + height: 1080, + isLandscape: true, + isMobile: false, + width: 1920, + }; + } + /** + * 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 ((0, node_fs_1.existsSync)("/tmp/chromium") === true) { + return Promise.resolve("/tmp/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 && (0, helper_1.isValidUrl)(input)) { + return this.executablePath(await (0, helper_1.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 ??= (0, node_path_1.join)(__dirname, "..", "bin"); + /** + * If the input directory doesn't exist, throw an error. + */ + if (!(0, node_fs_1.existsSync)(input)) { + throw new Error(`The input directory "${input}" does not exist.`); + } + // Extract the required files + const promises = [lambdafs_1.default.inflate(`${input}/chromium.br`)]; + if (this.graphics) { + // Only inflate graphics stack if needed + promises.push(lambdafs_1.default.inflate(`${input}/swiftshader.tar.br`)); + } + if ((0, helper_1.isRunningInAwsLambda)()) { + // If running in AWS Lambda, extract more required files + promises.push(lambdafs_1.default.inflate(`${input}/aws.tar.br`)); + } + // Await all extractions + const result = await Promise.all(promises); + // Returns the first result of the promise, which is the location of the `chromium` binary + return result.shift(); + } + /** + * Returns the headless mode. + * `true` means the 'old' (legacy, chromium < 112) headless mode. + * "new" means the 'new' headless mode. + * https://developer.chrome.com/articles/new-headless/#try-out-the-new-headless + * @returns true | "new" + */ + static get headless() { + return this.headlessMode; + } + /** + * Sets the headless mode. + * `true` means the 'old' (legacy, chromium < 112) headless mode. + * "new" means the 'new' headless mode. + * https://developer.chrome.com/articles/new-headless/#try-out-the-new-headless + * @default "new" + */ + static set setHeadlessMode(value) { + if ((typeof value === "string" && value !== "new") || + (typeof value === "boolean" && value !== true)) { + throw new Error(`Headless mode must be either \`true\` or 'new', you entered '${value}'`); + } + this.headlessMode = value; + } + /** + * 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. + * `false` will also skip the extract of the graphics driver, saving about a second during initial extract + * @default true + */ + static set setGraphicsMode(value) { + if (typeof value !== "boolean") { + throw new Error(`Graphics mode must be a boolean, you entered '${value}'`); + } + this.graphicsMode = value; + } +} +/** + * Determines the headless mode that chromium will run at + * https://developer.chrome.com/articles/new-headless/#try-out-the-new-headless + * @values true or "new" + */ +Chromium.headlessMode = "new"; +/** + * 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) + */ +Chromium.graphicsMode = true; +module.exports = Chromium; diff --git a/build/lambdafs.d.ts b/build/lambdafs.d.ts new file mode 100644 index 0000000..3780e9d --- /dev/null +++ b/build/lambdafs.d.ts @@ -0,0 +1,9 @@ +declare class LambdaFS { + /** + * 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. + */ + static inflate(filePath: string): Promise; +} +export = LambdaFS; diff --git a/build/lambdafs.js b/build/lambdafs.js new file mode 100644 index 0000000..714ef2d --- /dev/null +++ b/build/lambdafs.js @@ -0,0 +1,61 @@ +"use strict"; +const node_fs_1 = require("node:fs"); +const node_os_1 = require("node:os"); +const node_path_1 = require("node:path"); +const tar_fs_1 = require("tar-fs"); +const node_zlib_1 = require("node:zlib"); +class LambdaFS { + /** + * 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. + */ + static inflate(filePath) { + const output = filePath.includes("swiftshader") + ? (0, node_os_1.tmpdir)() + : (0, node_path_1.join)((0, node_os_1.tmpdir)(), (0, node_path_1.basename)(filePath).replace(/[.](?:t(?:ar(?:[.](?:br|gz))?|br|gz)|br|gz)$/i, "")); + return new Promise((resolve, reject) => { + if (filePath.includes("swiftshader")) { + if ((0, node_fs_1.existsSync)(`${output}/libGLESv2.so`)) { + return resolve(output); + } + } + else { + if ((0, node_fs_1.existsSync)(output) === true) { + return resolve(output); + } + } + let source = (0, node_fs_1.createReadStream)(filePath, { highWaterMark: 2 ** 23 }); + let target = null; + if (/[.](?:t(?:ar(?:[.](?:br|gz))?|br|gz))$/i.test(filePath) === true) { + target = (0, tar_fs_1.extract)(output); + target.once("finish", () => { + return resolve(output); + }); + } + else { + target = (0, node_fs_1.createWriteStream)(output, { mode: 0o700 }); + } + source.once("error", (error) => { + return reject(error); + }); + target.once("error", (error) => { + return reject(error); + }); + target.once("close", () => { + return resolve(output); + }); + if (/(?:br|gz)$/i.test(filePath) === true) { + source + .pipe(/br$/i.test(filePath) + ? (0, node_zlib_1.createBrotliDecompress)({ chunkSize: 2 ** 21 }) + : (0, node_zlib_1.createUnzip)({ chunkSize: 2 ** 21 })) + .pipe(target); + } + else { + source.pipe(target); + } + }); + } +} +module.exports = LambdaFS;