From bee6bda585de994624887bac715dc428a84228da Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Mon, 7 Sep 2026 17:44:11 -0700 Subject: [PATCH] Support explicit Puppeteer and Cloudflare screenshot providers --- package-lock.json | 376 ++++++++++++++++++++- package.json | 5 +- src/app/node.js | 6 +- src/app/screenshots/BrowserRunClient.js | 79 +++++ src/app/screenshots/PuppeteerRenderer.js | 21 ++ src/app/screenshots/ScreenshotGenerator.js | 84 +++++ src/app/screenshots/cli.js | 33 ++ src/app/screenshots/index.js | 1 - src/app/screenshots/node.js | 54 +++ src/app/screenshots/screenshots.js | 156 --------- src/app/social/posts/GearPost.js | 3 +- src/app/social/posts/NewWeaponPost.js | 3 +- src/app/social/posts/SalmonRunGearPost.js | 3 +- src/app/social/posts/SalmonRunPost.js | 3 +- src/app/social/posts/SchedulePost.js | 3 +- src/app/social/posts/SocialPostBase.js | 4 +- src/app/social/posts/SplatfestPost.js | 7 +- src/app/social/posts/index.js | 21 +- src/app/social/social.js | 9 +- test/screenshots/node.test.js | 96 ++++++ test/screenshots/screenshots.test.js | 30 +- test/social/posts.test.js | 5 +- workers/updater/src/posters.mjs | 8 +- 23 files changed, 799 insertions(+), 211 deletions(-) create mode 100644 src/app/screenshots/BrowserRunClient.js create mode 100644 src/app/screenshots/PuppeteerRenderer.js create mode 100644 src/app/screenshots/ScreenshotGenerator.js create mode 100644 src/app/screenshots/cli.js delete mode 100644 src/app/screenshots/index.js create mode 100644 src/app/screenshots/node.js delete mode 100644 src/app/screenshots/screenshots.js create mode 100644 test/screenshots/node.test.js diff --git a/package-lock.json b/package-lock.json index ad6a913..462cebf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "lodash": "^4.18.1", "mkdirp": "^3.0.1", "moment-timezone": "^0.6.3", + "puppeteer": "^25.10.0", + "serve-handler": "^6.1.7", "sharp": "^0.35.4", "v-click-outside": "^3.0.1", "vue": "^2.7.16", @@ -3877,6 +3879,186 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", @@ -5961,7 +6143,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -6118,7 +6299,6 @@ "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6420,6 +6600,22 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cjs-module-lexer": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", @@ -6676,7 +6872,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/connect-history-api-fallback": { @@ -7510,6 +7705,12 @@ "dev": true, "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "license": "BSD-3-Clause" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7854,7 +8055,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8767,12 +8967,23 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -10323,6 +10534,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -10806,6 +11029,27 @@ "node": ">= 0.6" } }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", @@ -11520,7 +11764,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11677,6 +11920,12 @@ "dev": true, "license": "ISC" }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", @@ -11692,6 +11941,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/modern-tar": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", + "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/module-alias": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.3.4.tgz", @@ -12416,6 +12674,12 @@ "node": ">=0.10.0" } }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "license": "(WTFPL OR MIT)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -13355,6 +13619,44 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz", + "integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.10.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", + "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/purgecss": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-8.0.0.tgz", @@ -14137,6 +14439,54 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" + }, + "node_modules/serve-handler/node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve-index": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", @@ -15256,6 +15606,12 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", @@ -15907,6 +16263,12 @@ "defaults": "^1.0.3" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -16591,7 +16953,6 @@ "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -16623,7 +16984,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 5155493..0512db7 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc", "social": "node src/app/index.js social", "social:test": "node src/app/index.js socialTest", - "admin:preview": "node workers/updater/preview/server.mjs" + "admin:preview": "node workers/updater/preview/server.mjs", + "screenshot": "node src/app/screenshots/cli.js" }, "dependencies": { "@atproto/api": "^0.20.42", @@ -59,6 +60,8 @@ "lodash": "^4.18.1", "mkdirp": "^3.0.1", "moment-timezone": "^0.6.3", + "puppeteer": "^25.10.0", + "serve-handler": "^6.1.7", "sharp": "^0.35.4", "v-click-outside": "^3.0.1", "vue": "^2.7.16", diff --git a/src/app/node.js b/src/app/node.js index f5fcb38..5a2ac5f 100644 --- a/src/app/node.js +++ b/src/app/node.js @@ -4,6 +4,7 @@ import FilesystemStorage from '../common/storage/FilesystemStorage.js'; import * as updater from './updater/index.js'; import * as social from './social/index.js'; +import { withScreenshots } from './screenshots/node.js'; export function filesystemStorage() { return { @@ -20,12 +21,13 @@ export async function updateAll() { } export async function sendStatuses() { - let result = await social.sendStatuses(filesystemStorage()); + let clients = social.createClients(); + let result = await withScreenshots(screenshots => social.sendStatuses(filesystemStorage(), clients, screenshots)); if (!result.ok) throw new Error('One or more social posts failed.'); return result; } export function testScreenshots() { - return social.testScreenshots(filesystemStorage()); + return withScreenshots(screenshots => social.testScreenshots(filesystemStorage(), [], screenshots)); } diff --git a/src/app/screenshots/BrowserRunClient.js b/src/app/screenshots/BrowserRunClient.js new file mode 100644 index 0000000..24252c4 --- /dev/null +++ b/src/app/screenshots/BrowserRunClient.js @@ -0,0 +1,79 @@ +import { logMessage } from '../log.js'; +import { fetchWithTimeout } from '../../common/fetch.js'; + +async function errorMessage(response) { + let body = await response.text(); + + try { + let messages = JSON.parse(body).errors?.map(error => error.message).filter(Boolean); + if (messages?.length) + return messages.join('; '); + } catch { + // Not JSON; use the body as-is + } + + return body || response.statusText || 'Unknown error'; +} + +// Browser Run's REST client works in both Node and Workers. +export default class BrowserRunClient { + constructor({ accountId, apiToken }) { + this.accountId = accountId; + this.apiToken = apiToken; + } + + async capture({ url, viewport, readySelector }) { + let missing = []; + if (!this.accountId) + missing.push('CLOUDFLARE_ACCOUNT_ID'); + if (!this.apiToken) + missing.push('CLOUDFLARE_BROWSER_RUN_API_TOKEN'); + if (missing.length) + throw new Error(`Missing screenshot configuration: ${missing.join(', ')}`); + + let endpoint = new URL(`/client/v4/accounts/${this.accountId}/browser-rendering/screenshot`, 'https://api.cloudflare.com'); + endpoint.searchParams.set('cacheTTL', '0'); + + let request = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: url.toString(), + viewport, + gotoOptions: { waitUntil: 'domcontentloaded', timeout: 10_000 }, + waitForSelector: { selector: readySelector, timeout: 10_000 }, + actionTimeout: 10_000, + setExtraHTTPHeaders: { 'Cache-Control': 'no-cache' }, + screenshotOptions: { type: 'png' }, + }), + }; + let image; + for (let attempt = 0; attempt <= 3; attempt++) { + try { + // Three 10s browser phases plus transport overhead; covers response body too. + let response = await fetchWithTimeout(endpoint, request, 40_000); + if (!response.ok) { + let message = await errorMessage(response); + let error = new Error(`Browser Rendering screenshot failed (${response.status}): ${message}`); + // Browser Run reports navigation/selector/action timeouts as 422 errors. + error.retryable = response.status === 408 || response.status >= 500 + || (response.status === 422 && /timeout|timed out/i.test(message)); + throw error; + } + image = new Uint8Array(await response.arrayBuffer()); + break; + } catch (error) { + let retryable = error.retryable ?? ['TimeoutError', 'TypeError'].includes(error.name); + if (!retryable || attempt === 3) + throw error; + let delayMs = 500 * 2 ** attempt; + logMessage('warn', 'Retrying Browser Run screenshot', { attempt: attempt + 1, delayMs, error: error.message }); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + return image; + } +} diff --git a/src/app/screenshots/PuppeteerRenderer.js b/src/app/screenshots/PuppeteerRenderer.js new file mode 100644 index 0000000..2c9c0fa --- /dev/null +++ b/src/app/screenshots/PuppeteerRenderer.js @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; + +// This module is imported only by Node entry points, never by the Worker. +export default class PuppeteerRenderer { + async capture({ url, viewport, readySelector }) { + let browser = await puppeteer.launch({ timeout: 10_000, protocolTimeout: 10_000 }); + try { + let page = await browser.newPage(); + page.setDefaultTimeout(10_000); + await page.setViewport(viewport); + await page.setCacheEnabled(false); + let response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10_000 }); + if (!response?.ok()) + throw new Error(`Screenshot page failed (${response?.status() ?? 'no response'}): ${url}`); + await page.waitForSelector(readySelector); + return new Uint8Array(await page.screenshot({ type: 'png' })); + } finally { + await browser.close(); + } + } +} diff --git a/src/app/screenshots/ScreenshotGenerator.js b/src/app/screenshots/ScreenshotGenerator.js new file mode 100644 index 0000000..bd91671 --- /dev/null +++ b/src/app/screenshots/ScreenshotGenerator.js @@ -0,0 +1,84 @@ +import { screenshotReadySelector } from '../../common/screenshot.js'; + +export const defaultViewport = { + // Use a 16:9 ratio for the public social image. + // 1216 was chosen as the width because of Bulma's "widescreen" breakpoint + width: 1216, + height: 684, + deviceScaleFactor: 2, +}; + +// Routes and image dimensions are shared regardless of where the browser runs. +export default class ScreenshotGenerator { + constructor(renderer, siteUrl) { + this.renderer = renderer; + this.siteUrl = siteUrl; + } + + async capture({ hash, url, viewport = {} }) { + if (!url) { + if (!this.siteUrl) + throw new Error('SITE_URL is required to capture a screenshot route.'); + url = new URL('/screenshots.html', this.siteUrl); + url.hash = hash; + } + let captureViewport = { ...defaultViewport, ...viewport }; + let image = await this.renderer.capture({ + url: new URL(url).toString(), + viewport: captureViewport, + readySelector: screenshotReadySelector, + }); + return { + image, + type: 'image/png', + width: captureViewport.width * captureViewport.deviceScaleFactor, + height: captureViewport.height * captureViewport.deviceScaleFactor, + }; + } + + captureScheduleScreenshot(now) { + let hash = `/schedules/${now}`; + + return this.capture({ hash }); + } + + captureGearScreenshot(now) { + let hash = `/splatNetGear/${now}`; + + return this.capture({ hash }); + } + + captureSalmonRunScreenshot(now, mode) { + let hash = `/salmonRun/${now}?mode=${mode}`; + + return this.capture({ hash }); + } + + captureSalmonRunGearScreenshot(now) { + let hash = `/salmonRunGear/${now}`; + + return this.capture({ hash }); + } + + captureNewWeaponScreenshot(now, weaponCount) { + let hash = `/newWeapon/${now}`; + + // There are a max of 4 weapons per row + const rows = Math.ceil(weaponCount / 4); + // Determine the image height based on the number of rows + let height = rows * 320; + // Add some extra height for the bottom banner + height += 60; + // Set a minimum overall image height + height = Math.max(height, 700); + + return this.capture({ hash, viewport: { height } }); + } + + captureSplatfestScreenshot(region, now, regions) { + regions = regions.join(','); + let hash = `/splatfest/${region}/${now}?regions=${regions}`; + + return this.capture({ hash }); + } +} diff --git a/src/app/screenshots/cli.js b/src/app/screenshots/cli.js new file mode 100644 index 0000000..90af181 --- /dev/null +++ b/src/app/screenshots/cli.js @@ -0,0 +1,33 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { parseArgs } from 'node:util'; +import { withScreenshots } from './node.js'; + +// Capture-only: no social clients, checkpoints, or production-data comparisons. +if (existsSync('.env')) + process.loadEnvFile(); + +try { + let { values } = parseArgs({ options: { + provider: { type: 'string' }, + url: { type: 'string' }, + hash: { type: 'string' }, + output: { type: 'string', default: 'dist/test-screenshots/capture.png' }, + help: { type: 'boolean' }, + } }); + if (values.help) { + console.log('Usage: npm run screenshot -- [--provider puppeteer|cloudflare] (--url | --hash ) [--output ]'); + console.log('Uses SCREENSHOT_PROVIDER and SITE_URL from the environment or .env. Without SITE_URL, Puppeteer serves dist/ temporarily.'); + } else { + if (!!values.url === !!values.hash) + throw new Error('Specify exactly one of --url or --hash. Use --help for examples of the options.'); + let result = await withScreenshots(screenshots => screenshots.capture(values), values); + await mkdir(dirname(values.output), { recursive: true }); + await writeFile(values.output, result.image); + console.log(`Saved ${result.width}×${result.height} PNG to ${values.output}`); + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/src/app/screenshots/index.js b/src/app/screenshots/index.js deleted file mode 100644 index 684c16e..0000000 --- a/src/app/screenshots/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './screenshots.js'; diff --git a/src/app/screenshots/node.js b/src/app/screenshots/node.js new file mode 100644 index 0000000..0a4d60f --- /dev/null +++ b/src/app/screenshots/node.js @@ -0,0 +1,54 @@ +import { createServer } from 'node:http'; +import { access } from 'node:fs/promises'; +import handler from 'serve-handler'; +import BrowserRunClient from './BrowserRunClient.js'; +import PuppeteerRenderer from './PuppeteerRenderer.js'; +import ScreenshotGenerator from './ScreenshotGenerator.js'; + +// Provider selection and the temporary file server belong to the Node command. +// The Worker constructs its BrowserRunClient directly. +export async function withScreenshots(callback, { + provider = process.env.SCREENSHOT_PROVIDER, + siteUrl = process.env.SITE_URL, + url, +} = {}) { + let renderer; + if (provider === 'cloudflare') { + renderer = new BrowserRunClient({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, + }); + if (!siteUrl && !url) + throw new Error('SITE_URL or --url is required for Cloudflare screenshots.'); + } else if (provider === 'puppeteer') { + renderer = new PuppeteerRenderer; + } else { + throw new Error('SCREENSHOT_PROVIDER must be "puppeteer" or "cloudflare" (or pass --provider).'); + } + + let server; + try { + if (!siteUrl && !url) { + await access('dist/screenshots.html').catch(() => { + throw new Error('Missing dist/screenshots.html. Run npm run build, or set SITE_URL to your dev server.'); + }); + server = createServer((request, response) => { + handler(request, response, { public: 'dist', cleanUrls: false, directoryListing: false }).catch(error => { + console.error(error); + response.destroy(); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + siteUrl = `http://127.0.0.1:${server.address().port}`; + } + return await callback(new ScreenshotGenerator(renderer, siteUrl)); + } finally { + if (server) { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } + } +} diff --git a/src/app/screenshots/screenshots.js b/src/app/screenshots/screenshots.js deleted file mode 100644 index 49620c8..0000000 --- a/src/app/screenshots/screenshots.js +++ /dev/null @@ -1,156 +0,0 @@ -import { logMessage } from '../log.js'; -import { fetchWithTimeout } from '../../common/fetch.js'; -import { screenshotReadySelector } from '../../common/screenshot.js'; - -// Screenshots of the site's screenshot page, rendered by Cloudflare Browser Rendering's REST -// API. It is plain fetch, so the same code runs under Node and in a Worker. The page is the -// deployed one (SITE_URL), which reads the published data. -// -// Configuration (environment variables / Worker secrets): -// SITE_URL e.g. https://splatoon2.ink -// CLOUDFLARE_ACCOUNT_ID -// CLOUDFLARE_BROWSER_RUN_API_TOKEN an API token with Browser Rendering permission - -const viewport = { - // Use a 16:9 ratio for the public social image. - // 1216 was chosen as the width because of Bulma's "widescreen" breakpoint - width: 1216, - height: 684, - deviceScaleFactor: 2, -}; - -function config() { - let names = ['SITE_URL', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_BROWSER_RUN_API_TOKEN']; - let missing = names.filter(name => !process.env[name]); - if (missing.length) - throw new Error(`Missing screenshot configuration: ${missing.join(', ')}`); - - return { - siteUrl: process.env.SITE_URL, - accountId: process.env.CLOUDFLARE_ACCOUNT_ID, - apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, - }; -} - -async function errorMessage(response) { - let body = await response.text(); - - try { - let messages = JSON.parse(body).errors?.map(error => error.message).filter(Boolean); - if (messages?.length) - return messages.join('; '); - } catch { - // Not JSON; use the body as-is - } - - return body || response.statusText || 'Unknown error'; -} - -/** - * @param {{ hash: string, viewport?: object }} options - * @returns {Promise<{ image: Uint8Array, type: string, width: number, height: number }>} - */ -export async function captureScreenshot({ hash, viewport: viewportOverrides }) { - let { siteUrl, accountId, apiToken } = config(); - let thisViewport = Object.assign({}, viewport, viewportOverrides); - - let url = new URL('/screenshots.html', siteUrl); - url.hash = hash; - - let endpoint = new URL(`/client/v4/accounts/${accountId}/browser-rendering/screenshot`, 'https://api.cloudflare.com'); - endpoint.searchParams.set('cacheTTL', '0'); - - let request = { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - url: url.toString(), - viewport: thisViewport, - gotoOptions: { waitUntil: 'domcontentloaded', timeout: 10_000 }, - waitForSelector: { selector: screenshotReadySelector, timeout: 10_000 }, - actionTimeout: 10_000, - setExtraHTTPHeaders: { 'Cache-Control': 'no-cache' }, - screenshotOptions: { type: 'png' }, - }), - }; - let image; - for (let attempt = 0; attempt <= 3; attempt++) { - try { - // Three 10s browser phases plus transport overhead; covers response body too. - let response = await fetchWithTimeout(endpoint, request, 40_000); - if (!response.ok) { - let message = await errorMessage(response); - let error = new Error(`Browser Rendering screenshot failed (${response.status}): ${message}`); - // Browser Run reports navigation/selector/action timeouts as 422 errors. - error.retryable = response.status === 408 || response.status >= 500 - || (response.status === 422 && /timeout|timed out/i.test(message)); - throw error; - } - image = new Uint8Array(await response.arrayBuffer()); - break; - } catch (error) { - let retryable = error.retryable ?? ['TimeoutError', 'TypeError'].includes(error.name); - if (!retryable || attempt === 3) - throw error; - let delayMs = 500 * 2 ** attempt; - logMessage('warn', 'Retrying Browser Run screenshot', { attempt: attempt + 1, delayMs, error: error.message }); - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } - - return { - image, - type: 'image/png', - width: thisViewport.width * thisViewport.deviceScaleFactor, - height: thisViewport.height * thisViewport.deviceScaleFactor, - }; -} - -export function captureScheduleScreenshot(now) { - let hash = `/schedules/${now}`; - - return captureScreenshot({ hash }); -} - -export function captureGearScreenshot(now) { - let hash = `/splatNetGear/${now}`; - - return captureScreenshot({ hash }); -} - -export function captureSalmonRunScreenshot(now, mode) { - let hash = `/salmonRun/${now}?mode=${mode}`; - - return captureScreenshot({ hash }); -} - -export function captureSalmonRunGearScreenshot(now) { - let hash = `/salmonRunGear/${now}`; - - return captureScreenshot({ hash }); -} - -export function captureNewWeaponScreenshot(now, weaponCount) { - let hash = `/newWeapon/${now}`; - - // There are a max of 4 weapons per row - const rows = Math.ceil(weaponCount / 4); - // Determine the image height based on the number of rows - let height = rows * 320; - // Add some extra height for the bottom banner - height += 60; - // Set a minimum overall image height - height = Math.max(height, 700); - - return captureScreenshot({ hash, viewport: { height } }); -} - -export function captureSplatfestScreenshot(region, now, regions) { - regions = regions.join(','); - let hash = `/splatfest/${region}/${now}?regions=${regions}`; - - return captureScreenshot({ hash }); -} diff --git a/src/app/social/posts/GearPost.js b/src/app/social/posts/GearPost.js index 7224d97..0379e91 100644 --- a/src/app/social/posts/GearPost.js +++ b/src/app/social/posts/GearPost.js @@ -1,5 +1,4 @@ import SocialPostBase from './SocialPostBase.js'; -import { captureGearScreenshot } from '../../screenshots/index.js'; import { getTopOfCurrentHour } from '../../../common/time.js'; export default class GearPost extends SocialPostBase { @@ -29,7 +28,7 @@ export default class GearPost extends SocialPostBase { getImage(data) { let now = getTopOfCurrentHour(); - return captureGearScreenshot(now); + return this.screenshots.captureGearScreenshot(now); } getPublicImageFilename() { diff --git a/src/app/social/posts/NewWeaponPost.js b/src/app/social/posts/NewWeaponPost.js index f7dd573..fce6393 100644 --- a/src/app/social/posts/NewWeaponPost.js +++ b/src/app/social/posts/NewWeaponPost.js @@ -1,5 +1,4 @@ import SocialPostBase from './SocialPostBase.js'; -import { captureNewWeaponScreenshot } from '../../screenshots/index.js'; export default class NewWeaponPost extends SocialPostBase { getKey() { return 'weapon'; } @@ -31,7 +30,7 @@ export default class NewWeaponPost extends SocialPostBase { } getImage(data) { - return captureNewWeaponScreenshot(data[0].release_time, data.length); + return this.screenshots.captureNewWeaponScreenshot(data[0].release_time, data.length); } getText(data) { diff --git a/src/app/social/posts/SalmonRunGearPost.js b/src/app/social/posts/SalmonRunGearPost.js index 5a1cf94..1a87207 100644 --- a/src/app/social/posts/SalmonRunGearPost.js +++ b/src/app/social/posts/SalmonRunGearPost.js @@ -1,5 +1,4 @@ import SocialPostBase from './SocialPostBase.js'; -import { captureSalmonRunGearScreenshot } from '../../screenshots/index.js'; import moment from 'moment-timezone'; export default class SalmonRunGearPost extends SocialPostBase { @@ -22,7 +21,7 @@ export default class SalmonRunGearPost extends SocialPostBase { } getImage(data) { - return captureSalmonRunGearScreenshot(data.available_time); + return this.screenshots.captureSalmonRunGearScreenshot(data.available_time); } getText(data) { diff --git a/src/app/social/posts/SalmonRunPost.js b/src/app/social/posts/SalmonRunPost.js index 133f19b..2844e8e 100644 --- a/src/app/social/posts/SalmonRunPost.js +++ b/src/app/social/posts/SalmonRunPost.js @@ -1,5 +1,4 @@ import SocialPostBase from './SocialPostBase.js'; -import { captureSalmonRunScreenshot } from '../../screenshots/index.js'; const PREVIOUS_SCHEDULE_KEY = 'salmonrun-previousSchedule.json'; @@ -83,7 +82,7 @@ export default class SalmonRunPost extends SocialPostBase { async getImage(data) { let mode = (data.current) ? 'current' : 'upcoming'; - return captureSalmonRunScreenshot(await this.getDataTime(), mode); + return this.screenshots.captureSalmonRunScreenshot(await this.getDataTime(), mode); } async getText(data) { diff --git a/src/app/social/posts/SchedulePost.js b/src/app/social/posts/SchedulePost.js index 5a54b82..6d48cbd 100644 --- a/src/app/social/posts/SchedulePost.js +++ b/src/app/social/posts/SchedulePost.js @@ -1,5 +1,4 @@ import SocialPostBase from './SocialPostBase.js'; -import { captureScheduleScreenshot } from '../../screenshots/index.js'; import finalFest from '../../../common/data/finalFest.json' with { type: 'json' }; import shiftyStations from '../../../common/data/shiftyStations.json' with { type: 'json' }; @@ -37,7 +36,7 @@ export default class SchedulePost extends SocialPostBase { } getImage(data) { - return captureScheduleScreenshot(data.regular.start_time); + return this.screenshots.captureScheduleScreenshot(data.regular.start_time); } getPublicImageFilename() { diff --git a/src/app/social/posts/SocialPostBase.js b/src/app/social/posts/SocialPostBase.js index 3e535ed..3de8bf7 100644 --- a/src/app/social/posts/SocialPostBase.js +++ b/src/app/social/posts/SocialPostBase.js @@ -9,11 +9,13 @@ export default class SocialPostBase { * publicStorage holds the site data this post reads and the public copy of its image; * privateStorage holds the last-posted times and other state. * @param {object[]} clients the social clients to post through (see ../clients/) + * @param {import('../../screenshots/ScreenshotGenerator.js').default} screenshots */ - constructor(storage = {}, clients = []) { + constructor(storage = {}, clients = [], screenshots) { this.publicStorage = storage.publicStorage; this.privateStorage = storage.privateStorage; this.clients = clients; + this.screenshots = screenshots; } async maybePost() { diff --git a/src/app/social/posts/SplatfestPost.js b/src/app/social/posts/SplatfestPost.js index 1a3b313..7cdccca 100644 --- a/src/app/social/posts/SplatfestPost.js +++ b/src/app/social/posts/SplatfestPost.js @@ -1,10 +1,9 @@ import SocialPostBase from './SocialPostBase.js'; -import { captureSplatfestScreenshot } from '../../screenshots/index.js'; import { splatoonRegions } from '../../../common/regions.js'; export default class SplatfestPost extends SocialPostBase { - constructor(region, storage, clients) { - super(storage, clients); + constructor(region, storage, clients, screenshots) { + super(storage, clients, screenshots); this.region = region; this.regionInfo = this.getRegionInfo(); @@ -109,7 +108,7 @@ export default class SplatfestPost extends SocialPostBase { } async getImage(data) { - return captureSplatfestScreenshot(this.region, await this.getDataTime(), await this.regions(data)); + return this.screenshots.captureSplatfestScreenshot(this.region, await this.getDataTime(), await this.regions(data)); } async getText(data) { diff --git a/src/app/social/posts/index.js b/src/app/social/posts/index.js index e1f99a9..ec79da0 100644 --- a/src/app/social/posts/index.js +++ b/src/app/social/posts/index.js @@ -9,17 +9,18 @@ import SplatfestPost from './SplatfestPost.js'; /** * @param {{ publicStorage: object, privateStorage: object }} storage * @param {object[]} clients + * @param {import('../../screenshots/ScreenshotGenerator.js').default} screenshots */ -export function createPosts(storage, clients) { +export function createPosts(storage, clients, screenshots) { return [ - new SchedulePost(storage, clients), - new GearPost(storage, clients), - new SalmonRunPost(storage, clients), - // new SalmonRunGearPost(storage, clients), - new NewWeaponPost(storage, clients), - new NewStagePost(storage, clients), - new SplatfestPost('na', storage, clients), - new SplatfestPost('eu', storage, clients), - new SplatfestPost('jp', storage, clients), + new SchedulePost(storage, clients, screenshots), + new GearPost(storage, clients, screenshots), + new SalmonRunPost(storage, clients, screenshots), + // new SalmonRunGearPost(storage, clients, screenshots), + new NewWeaponPost(storage, clients, screenshots), + new NewStagePost(storage, clients, screenshots), + new SplatfestPost('na', storage, clients, screenshots), + new SplatfestPost('eu', storage, clients, screenshots), + new SplatfestPost('jp', storage, clients, screenshots), ]; } diff --git a/src/app/social/social.js b/src/app/social/social.js index aca2481..61659dc 100644 --- a/src/app/social/social.js +++ b/src/app/social/social.js @@ -9,17 +9,18 @@ export function createClients() { * Post whatever is due for the current hour. * @param {{ publicStorage: object, privateStorage: object }} storage * @param {object[]} [clients] + * @param {import('../screenshots/ScreenshotGenerator.js').default} screenshots */ -export async function sendStatuses(storage, clients = createClients()) { +export async function sendStatuses(storage, clients = createClients(), screenshots) { let posts = []; - for (let post of createPosts(storage, clients)) { + for (let post of createPosts(storage, clients, screenshots)) { let result = await post.maybePost(); posts.push({ key: post.getKey(), ...(result || { ok: true, skipped: true }) }); } return { ok: posts.every(post => post.ok), posts }; } -export async function testScreenshots(storage, clients = createClients()) { - for (let post of createPosts(storage, clients)) +export async function testScreenshots(storage, clients = createClients(), screenshots) { + for (let post of createPosts(storage, clients, screenshots)) await post.saveTestScreenshot(); } diff --git a/test/screenshots/node.test.js b/test/screenshots/node.test.js new file mode 100644 index 0000000..0f3356b --- /dev/null +++ b/test/screenshots/node.test.js @@ -0,0 +1,96 @@ +import { test, mock, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import PuppeteerRenderer from '../../src/app/screenshots/PuppeteerRenderer.js'; +import { withScreenshots } from '../../src/app/screenshots/node.js'; + +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); +afterEach(() => mock.restoreAll()); + +test('local browser uses page readiness and closes on success or failure', async () => { + let closed = 0; + let fail = false; + let ready; + let page = { + setDefaultTimeout(timeout) { assert.equal(timeout, 10_000); }, + async setViewport() {}, + async setCacheEnabled() {}, + async goto(url, options) { + assert.equal(url, 'http://localhost:8080/screenshots.html'); + assert.equal(options.waitUntil, 'domcontentloaded'); + return { ok: () => true }; + }, + async waitForSelector(selector) { + ready = selector; + if (fail) + throw new Error('Page not ready'); + }, + async screenshot(options) { + assert.equal(options.type, 'png'); + return PNG; + }, + }; + mock.method(puppeteer, 'launch', async () => ({ + newPage: async () => page, + close: async () => { closed++; }, + })); + let renderer = new PuppeteerRenderer; + let options = { url: 'http://localhost:8080/screenshots.html', viewport: {}, readySelector: '[data-screenshot-ready="true"]' }; + assert.deepEqual(await renderer.capture(options), PNG); + assert.equal(ready, options.readySelector); + fail = true; + await assert.rejects(renderer.capture(options), /Page not ready/); + assert.equal(closed, 2); +}); + +test('Node can select Cloudflare for a direct URL without SITE_URL or social data', async () => { + let requests = []; + mock.method(globalThis, 'fetch', async (url, init) => { + requests.push(JSON.parse(init.body)); + return new Response(PNG); + }); + let previous = { ...process.env }; + try { + process.env.CLOUDFLARE_ACCOUNT_ID = 'account'; + process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token'; + let url = 'https://dev.example.test/screenshots.html#/schedules/3600'; + let result = await withScreenshots(screenshots => screenshots.capture({ url }), { + provider: 'cloudflare', siteUrl: '', url, + }); + assert.deepEqual(result.image, PNG); + assert.equal(requests[0].url, url); + } finally { + process.env = previous; + } +}); + +test('provider selection is explicit and Cloudflare requires a reachable target', async () => { + await assert.rejects(withScreenshots(() => {}, { provider: 'unknown' }), /SCREENSHOT_PROVIDER/); + await assert.rejects(withScreenshots(() => {}, { provider: 'cloudflare', siteUrl: '' }), /SITE_URL or --url/); +}); + +test('temporary dist server is loopback-only and closes when capture fails', async () => { + let { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + let { tmpdir } = await import('node:os'); + let { join } = await import('node:path'); + let directory = await mkdtemp(join(tmpdir(), 'screenshot-server-')); + let originalDirectory = process.cwd(); + let siteUrl; + try { + await mkdir(join(directory, 'dist')); + await writeFile(join(directory, 'dist/screenshots.html'), 'local fixture'); + process.chdir(directory); + mock.method(PuppeteerRenderer.prototype, 'capture', async () => { throw new Error('Capture failed'); }); + await assert.rejects(withScreenshots(async screenshots => { + siteUrl = screenshots.siteUrl; + assert.equal(new URL(siteUrl).hostname, '127.0.0.1'); + let response = await fetch(new URL('/screenshots.html', siteUrl)); + assert.match(await response.text(), /local fixture/); + return screenshots.capture({ hash: '/schedules/3600' }); + }, { provider: 'puppeteer', siteUrl: '' }), /Capture failed/); + await assert.rejects(fetch(siteUrl)); + } finally { + process.chdir(originalDirectory); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/test/screenshots/screenshots.test.js b/test/screenshots/screenshots.test.js index 999c27f..9543104 100644 --- a/test/screenshots/screenshots.test.js +++ b/test/screenshots/screenshots.test.js @@ -1,6 +1,14 @@ import { test, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { captureScreenshot, captureNewWeaponScreenshot, captureSplatfestScreenshot } from '../../src/app/screenshots/screenshots.js'; +import BrowserRunClient from '../../src/app/screenshots/BrowserRunClient.js'; +import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js'; + +function screenshots() { + return new ScreenshotGenerator(new BrowserRunClient({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, + }), process.env.SITE_URL); +} const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); @@ -22,7 +30,7 @@ afterEach(() => mock.restoreAll()); test('asks Browser Rendering for the deployed screenshot page at the default viewport', async () => { const requests = fakeBrowserRendering(); - const result = await captureScreenshot({ hash: '/schedules/3600' }); + const result = await screenshots().capture({ hash: '/schedules/3600' }); assert.equal(requests.length, 1); const [{ url, headers, body }] = requests; @@ -40,25 +48,25 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie test('the new-weapon screenshot grows with the number of weapons, and splatfest passes its regions', async () => { const requests = fakeBrowserRendering(); - const tall = await captureNewWeaponScreenshot(3600, 9); + const tall = await screenshots().captureNewWeaponScreenshot(3600, 9); assert.equal(requests[0].body.viewport.height, 3 * 320 + 60); assert.equal(tall.height, (3 * 320 + 60) * 2); - const short = await captureNewWeaponScreenshot(3600, 1); + const short = await screenshots().captureNewWeaponScreenshot(3600, 1); assert.equal(requests[1].body.viewport.height, 700); assert.equal(short.width, 2432); - await captureSplatfestScreenshot('na', 3600, ['na', 'eu']); + await screenshots().captureSplatfestScreenshot('na', 3600, ['na', 'eu']); assert.equal(requests[2].body.url, 'https://example.test/screenshots.html#/splatfest/na/3600?regions=na,eu'); }); test('reports API errors with Cloudflare\'s message', async () => { fakeBrowserRendering(() => Response.json({ errors: [{ message: 'Invalid token' }] }, { status: 401 })); - await assert.rejects(captureScreenshot({ hash: '/x' }), /Browser Rendering screenshot failed \(401\): Invalid token/); + await assert.rejects(screenshots().capture({ hash: '/x' }), /Browser Rendering screenshot failed \(401\): Invalid token/); }); test('fails clearly when configuration is missing', async () => { delete process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN; - await assert.rejects(captureScreenshot({ hash: '/x' }), /Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/); + await assert.rejects(screenshots().capture({ hash: '/x' }), /Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/); }); function immediateBackoff() { @@ -76,7 +84,7 @@ test('retries timeout responses and succeeds with the same screenshot request', const requests = fakeBrowserRendering(() => ++attempt < 3 ? Response.json({ errors: [{ message: 'Navigation timeout of 10000 ms exceeded' }] }, { status: 422 }) : new Response(PNG)); - const result = await captureScreenshot({ hash: '/schedules/3600' }); + const result = await screenshots().capture({ hash: '/schedules/3600' }); assert.deepEqual(result.image, PNG); assert.equal(requests.length, 3); assert.deepEqual(requests[0].body, requests[2].body); @@ -86,7 +94,7 @@ test('retries timeout responses and succeeds with the same screenshot request', test('stops after three retries and preserves the final error', async () => { const delays = immediateBackoff(); const requests = fakeBrowserRendering(() => new Response('upstream unavailable', { status: 503 })); - await assert.rejects(captureScreenshot({ hash: '/x' }), /503.*upstream unavailable/); + await assert.rejects(screenshots().capture({ hash: '/x' }), /503.*upstream unavailable/); assert.equal(requests.length, 4); assert.deepEqual(delays, [500, 1000, 2000]); }); @@ -96,7 +104,7 @@ test('does not retry authentication, rate limits or non-timeout validation error for (const status of [401, 403, 429, 422]) { let count = 0; const requests = fakeBrowserRendering(() => { count++; return new Response('invalid request', { status }); }); - await assert.rejects(captureScreenshot({ hash: '/x' }), new RegExp(String(status))); + await assert.rejects(screenshots().capture({ hash: '/x' }), new RegExp(String(status))); assert.equal(count, 1); assert.equal(requests.length, 1); } @@ -113,6 +121,6 @@ test('retries network and client deadline failures, including while reading the if (attempt === 3) return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError('connection reset')); } })); return new Response(PNG); }); - assert.deepEqual((await captureScreenshot({ hash: '/x' })).image, PNG); + assert.deepEqual((await screenshots().capture({ hash: '/x' })).image, PNG); assert.equal(requests.length, 4); }); diff --git a/test/social/posts.test.js b/test/social/posts.test.js index 8781a5f..cffd9d5 100644 --- a/test/social/posts.test.js +++ b/test/social/posts.test.js @@ -25,9 +25,10 @@ function pinTime(post, time) { } test('createPosts builds every post against the same storage and clients', () => { - const posts = createPosts(s, [client]); + const screenshots = {}; + const posts = createPosts(s, [client], screenshots); assert.deepEqual(posts.map(t => t.getKey()), ['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp']); - assert.ok(posts.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client)); + assert.ok(posts.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client && t.screenshots === screenshots)); }); test('schedule: posts the rotation for the current hour, with new-stage wording when the stage is new', async () => { diff --git a/workers/updater/src/posters.mjs b/workers/updater/src/posters.mjs index 5f5fb32..01d0870 100644 --- a/workers/updater/src/posters.mjs +++ b/workers/updater/src/posters.mjs @@ -1,3 +1,5 @@ +import BrowserRunClient from '../../../src/app/screenshots/BrowserRunClient.js'; +import ScreenshotGenerator from '../../../src/app/screenshots/ScreenshotGenerator.js'; import stringify from 'json-stable-stringify'; import { sendStatuses, createClients } from '../../../src/app/social/index.js'; import { fetchWithTimeout } from '../../../src/common/fetch.js'; @@ -32,7 +34,11 @@ export async function runPosters(env) { for (let client of clients) if (await client.canSend()) enabled.push(client.key); - let result = await sendStatuses(storage, clients); + let screenshots = new ScreenshotGenerator(new BrowserRunClient({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, + }), process.env.SITE_URL); + let result = await sendStatuses(storage, clients, screenshots); let summary = { ...result, ms: Date.now() - started, clients: enabled }; createLogger('social')[summary.ok ? 'info' : 'error']('Social run finished', summary); return summary;