Using Google Chrome instead of Chromium in Google Cloud Functions
+May 5, 2024
+diff --git a/2024/05/google-chrome-cloud-functions.html b/2024/05/google-chrome-cloud-functions.html new file mode 100644 index 0000000..cd43762 --- /dev/null +++ b/2024/05/google-chrome-cloud-functions.html @@ -0,0 +1,218 @@ + + +
+ +May 5, 2024
+When using Puppeteer, Playwright and similar, you need to have Chrome +installed. When youāre running on AWS Lambda or Google Cloud Functions, +it can get tricky.
+Google Cloud Functions used to bundle Chromium in their base images,
+but itās been a few years itās no longer the case. Thatās where packages
+like chrome-aws-lambda
+come in handy, by bundling Chromium directly inside a npm package, and
+exposing a function that extracts the Chromium binary and returns the
+path:
const chromium = require('chrome-aws-lambda')
+
+const path = await chromium.executablePath
+
+Note: unnecessary pedantic detail: the above code doesnāt look like a function, +but it is, in fact, +a getter function that returns a promise. š
+However thatās Chromium, and you may have reasons to want Google Chrome +instead (mainly, proprietary codecs).
+This article is about Google Cloud Functions, but if youāre on AWS +Lambda, the above option is your best bet. Because of the Lambda total +size limit of 250 MB (all layers combined), itās really hard to get a +binary of Chrome that fits in there.
+Thatās why chrome-aws-lambda
uses LambdaFS
+under the hood, to aggressively compress the Chrome installation with
+Brotli and make it fit in that limited space.
But again with that build, you wonāt have proprietary codecs. I tried to +trim down a Chrome Linux build and compress it with the same technique +but never managed to make it fit on AWS Lambda. Recent Chrome versions +are just too big.
+Thereās another option, which is to compile Chromium yourself with +proprietary codecs. I never found any prebuilt binaries of Chromium that +include proprietary codecs (maybe because of license issues +redistributing them š) so youāre on your own here.
+Remotion successfully does that for +Remotion Lambda. +Hereās their instructions +to compile Chromium with proprietary codecs for Lambda.
+Fair warning: it gets hairy, fast.
+Google Cloud Functions is more generous as for bundle size, so we donāt +need to resort to those tricks, and we can include a complete, +uncompressed, Google Chrome installation.
+Google publishes Chrome for Testing, +builds specifically made +for headless usage.
+We can just download the latest build from there as part of the
+gcp-build
script in our package.json
.
{
+ "scripts": {
+ "gcp-build": "curl -s -O 'https://storage.googleapis.com/chrome-for-testing-public/124.0.6367.91/linux64/chrome-linux64.zip' && unzip chrome-linux64.zip && rm chrome-linux64.zip"
+ }
+}
+
+Note: the gcp-build
script allows you to run a custom build step
+in Google Cloud Build, which is what Cloud Functions (both 1st and 2nd
+gen, as well as Cloud Run and App Engine) use to build your function
+image.
It would work just fine with a postinstall
script as well, but
+gcp-build
makes sure you run it only on Google Cloud Build, which is
+probably desirable in this particular case.
You will then have the Chrome binary in chrome-linux64/chrome
, that
+you can pass to the tool of your choice.
Courtesy of this post, +with Puppeteer, you donāt need to download Chrome manually, since it +provides a nifty script to do just that.
+Actually, Puppeteerās postinstall
script
+automatically downloads the latest version of Chrome for Testing for
+your platform.
The caveat is that this script by default installs it to
+~/.cache/puppeteer
, which in the case of Google Cloud Build, is not
+gonna be preserved in the final image. So we need to instruct Puppeteer
+to install Chrome in a directory that Cloud Build will keep.
This can be done with the following .puppeteerrc.js
:
module.exports = {
+ cacheDirectory: `${__dirname}/.cache/puppeteer`
+}
+
+But even then, thereās another caveat. Puppeteerās postinstall
script
+will only run after it gets installed. However, because of build
+caching, you will get in a state where node_modules
is restored, with
+Puppeteer already installed (so postinstall
will not run), but the
+.cache/puppeteer
directory will also not be restored.
To mitigate that, we need to make sure to install Chrome systematically.
+Again we can leverage the gcp-build
for that:
{
+ "scripts": {
+ "gcp-build": "npx puppeteer browsers install chrome"
+ }
+}
+
+Note: you could call Puppeteerās postinstall
script directly by
+doing node node_modules/puppeteer/install.mjs
instead, but I found the
+above command cleaner.
The good thing is that this script knows to not re-download Chrome if
+itās already found in the cache directory, so when the postinstall
+script does run, the extra gcp-build
command will be a no-op.
Google Cloud Functions used to bundle Chromium in their base images,
+but itās been a few years itās no longer the case. Thatās where packages
+like chrome-aws-lambda
+come in handy, by bundling Chromium directly inside a npm package, and
+exposing a function that extracts the Chromium binary and returns the
+path:
const chromium = require('chrome-aws-lambda')
+
+const path = await chromium.executablePath
+
+Note: unnecessary pedantic detail: the above code doesnāt look like a function, +but it is, in fact, +a getter function that returns a promise. š
+However thatās Chromium, and you may have reasons to want Google Chrome +instead (mainly, proprietary codecs).
+This article is about Google Cloud Functions, but if youāre on AWS +Lambda, the above option is your best bet. Because of the Lambda total +size limit of 250 MB (all layers combined), itās really hard to get a +binary of Chrome that fits in there.
+Thatās why chrome-aws-lambda
uses LambdaFS
+under the hood, to aggressively compress the Chrome installation with
+Brotli and make it fit in that limited space.
But again with that build, you wonāt have proprietary codecs. I tried to +trim down a Chrome Linux build and compress it with the same technique +but never managed to make it fit on AWS Lambda. Recent Chrome versions +are just too big.
+Thereās another option, which is to compile Chromium yourself with +proprietary codecs. I never found any prebuilt binaries of Chromium that +include proprietary codecs (maybe because of license issues +redistributing them š) so youāre on your own here.
+Remotion successfully does that for +Remotion Lambda. +Hereās their instructions +to compile Chromium with proprietary codecs for Lambda.
+Fair warning: it gets hairy, fast.
+Google Cloud Functions is more generous as for bundle size, so we donāt +need to resort to those tricks, and we can include a complete, +uncompressed, Google Chrome installation.
+Google publishes Chrome for Testing, +builds specifically made +for headless usage.
+We can just download the latest build from there as part of the
+gcp-build
script in our package.json
.
{
+ "scripts": {
+ "gcp-build": "curl -s -O 'https://storage.googleapis.com/chrome-for-testing-public/124.0.6367.91/linux64/chrome-linux64.zip' && unzip chrome-linux64.zip && rm chrome-linux64.zip"
+ }
+}
+
+Note: the gcp-build
script allows you to run a custom build step
+in Google Cloud Build, which is what Cloud Functions (both 1st and 2nd
+gen, as well as Cloud Run and App Engine) use to build your function
+image.
It would work just fine with a postinstall
script as well, but
+gcp-build
makes sure you run it only on Google Cloud Build, which is
+probably desirable in this particular case.
You will then have the Chrome binary in chrome-linux64/chrome
, that
+you can pass to the tool of your choice.
Courtesy of this post, +with Puppeteer, you donāt need to download Chrome manually, since it +provides a nifty script to do just that.
+Actually, Puppeteerās postinstall
script
+automatically downloads the latest version of Chrome for Testing for
+your platform.
The caveat is that this script by default installs it to
+~/.cache/puppeteer
, which in the case of Google Cloud Build, is not
+gonna be preserved in the final image. So we need to instruct Puppeteer
+to install Chrome in a directory that Cloud Build will keep.
This can be done with the following .puppeteerrc.js
:
module.exports = {
+ cacheDirectory: `${__dirname}/.cache/puppeteer`
+}
+
+But even then, thereās another caveat. Puppeteerās postinstall
script
+will only run after it gets installed. However, because of build
+caching, you will get in a state where node_modules
is restored, with
+Puppeteer already installed (so postinstall
will not run), but the
+.cache/puppeteer
directory will also not be restored.
To mitigate that, we need to make sure to install Chrome systematically.
+Again we can leverage the gcp-build
for that:
{
+ "scripts": {
+ "gcp-build": "npx puppeteer browsers install chrome"
+ }
+}
+
+Note: you could call Puppeteerās postinstall
script directly by
+doing node node_modules/puppeteer/install.mjs
instead, but I found the
+above command cleaner.
The good thing is that this script knows to not re-download Chrome if
+itās already found in the cache directory, so when the postinstall
+script does run, the extra gcp-build
command will be a no-op.
This was harder than expected. Iāll tell you the whole story because I -find it fun and interesting, but feel free to jump straight to the solution.
-NSCursor
In a Mac app, the NSCursor
class exposes a number of default cursors,
-like the arrow ,
-I-beam ,
-pointing hand ,
-various resize cursors, and even a cute ādisappearing itemā cursor
-(that I kinda want to name āpoofā for some reason).
There is also a crosshair cursor , -however itās not the same that the system screenshot utility uses. And -the camera cursor is nowhere to be found.
-So our last resort is to set a custom cursor from an image, e.g. for a -cursor thatās 32x32 pixels where we want the āhot spotā to be in the -middle:
-let image = NSImage(named: "cursor.png")
-let hotSpot = NSPoint(x: 16, y: 16)
-let cursor = NSCursor(image: image, hotSpot: hotSpot)
-
-But what image do we use here?
-By doing a bit of digging in the /System
directory, we find the
-following path:
/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors
-
-This seems to contain all the system cursors, one directory for each,
-containing a cursor.pdf
and info.plist
!
Here, we effectively have screenshotselection
that matches the
-screen capture utilityās crosshair, and screenshotwindow
that matches
-the camera cursor shown during window selection. Neat.
Parsing the info.plist
, we find the hot spot coordinates:
$ plutil -p /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors/screenshotselection/info.plist
-{
- "hotx" => 15
- "hotx-scaled" => 15
- "hoty" => 15
- "hoty-scaled" => 15
-}
-
-We can now load those programmatically:
-func loadCursor(_ name: String) -> NSCursor? {
- let root =
- "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors"
-
- guard let data = FileManager.default.contents(atPath: "\(root)/\(name)/info.plist")
- else {
- return nil
- }
-
- guard
- let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil)
- as? [String: Any]
- else {
- return nil
- }
-
- guard let pdfData = try? Data(contentsOf: URL(fileURLWithPath: "\(root)/\(name)/cursor.pdf"))
- else {
- return nil
- }
-
- guard let cursorImage = NSImage(data: pdfData) else {
- return nil
- }
-
- let hotSpot = NSPoint(
- x: plist["hotx"] as! Int? ?? Int(cursorImage.size.width) / 2,
- y: plist["hoty"] as! Int? ?? Int(cursorImage.size.height) / 2
- )
-
- return NSCursor(image: cursorImage, hotSpot: hotSpot)
-}
-
-Letās use this function in a basic example to demonstrate it:
-import Cocoa
-
-let app = NSApplication.shared
-
-if let cursor = loadCursor("screenshotselection") {
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- cursor.set()
- }
-}
-
-app.setActivationPolicy(.regular)
-app.activate(ignoringOtherApps: true)
-app.run()
-
-Note: here we call cursor.set()
after a delay because it
-doesnāt
-always work when called right
-away for reasons that are not familiar to me.
In a real app, you probably want to subclass NSView
, override
-resetCursorRects
, and call addCursorRect
in it.
This actually looks good for the camera! But for the crosshair, it -doesnāt seem to match the original one.
-The original crosshair size appears to be 50x50 pixels, while this one -is 46x46. More importantly, the original one has some kind of light outline -that makes it visible on darker backgrounds, that is completely missing -from that cursor PDF we just found. You can see the difference easily:
-Original | -Custom | -
---|---|
- | - |
- | - |
So the screen capture utility doesnāt seem to be using this cursor from
-HIServices.framework
.
I tried exploring the contents of the screen capture app in
-/System/Library/CoreServices/screencaptureui.app
, especially the
-Contents/Resources/Assets.car
file, exploring it using
-Asset Catalog Tinkerer,
-but it didnāt contain anything useful.
The next idea I tried was to see if I could somehow access the cursor -data from other apps from my Swift app.
-It turns out NSCursor
exposes a currentSystem
-property, containing current system cursor (as opposed to
-NSCursor.current
that contains your own applicationās current cursor).
This way we can easily access the image data of the currentSystem
-cursor, as well as its hotSpot
to be used later in our own custom
-cursor.
import Cocoa
-
-let cursor = NSCursor.currentSystem!
-
-print(cursor.hotSpot)
-
-let image = cursor.image.cgImage(forProposedRect: nil, context: nil, hints: nil)!
-let bitmap = NSBitmapImageRep(cgImage: image)
-let data = bitmap.representation(using: .png, properties: [:])!
-try! data.write(to: URL(fileURLWithPath: "cursor.png"))
-
-We can put this code in a file test.swift
, and run it with sleep 5 && swift test.swift
.
-This gives us 5 seconds to do whatever is needed to show the cursor we
-want to harvest, before our script actually runs and saves the current
-system cursor to a PNG file.
In the case of the screen capture utility crosshair, Iāve got this -(pictured over transparent, grey and dark background to show how well it -reacts to those):
-- | - | - |
Perfect. š
-I didnāt want to get into adding support for showing the dynamic -coordinates as part of the cursor, so as far as Iām concerned, I got rid -of those and used just the crosshair in my app.
-I hope you found this post useful! Now if you want to get the cursor -data from any app, in its original transparent quality, you can use the -simple script above to do so. Enjoy!
- ]]>