From b62db8b1493bfd73e7c92d38b90111abc84ce443 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 29 Aug 2026 19:09:22 -0700 Subject: [PATCH 1/5] Add Cloudflare Browser Run screenshots --- .env.example | 9 +- CLAUDE.md | 4 +- app/screenshots/ScreenshotHelper.mjs | 103 ++++++++++++++++++++-- app/screenshots/ScreenshotHelper.test.mjs | 84 ++++++++++++++++++ app/social/StatusGeneratorManager.mjs | 2 +- docker-compose.override.yml.dev.example | 6 ++ docker-compose.override.yml.prod.example | 8 +- docker-compose.yml | 8 +- readme.md | 6 ++ 9 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 app/screenshots/ScreenshotHelper.test.mjs diff --git a/.env.example b/.env.example index 1fc6efa..2b03b27 100644 --- a/.env.example +++ b/.env.example @@ -20,11 +20,18 @@ SENTRY_DSN= # Archive all data (can use a lot of disk space) ARCHIVE_DATA=false -# Browserless (for screenshots) +# Screenshots (choose cloudflare or browserless explicitly) +SCREENSHOT_PROVIDER=browserless + +# Browserless (local development) BROWSERLESS_ENDPOINT=ws://localhost:3000 SCREENSHOT_HOST=host.docker.internal BROWSERLESS_CONCURRENT=2 +# Cloudflare Browser Run Quick Actions (production) +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_BROWSER_RUN_API_TOKEN= + # S3 parameters AWS_S3_ENDPOINT= AWS_REGION= diff --git a/CLAUDE.md b/CLAUDE.md index b042c41..2943b0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ npm start # Full production: sync → splatnet → social → cron **Data pipeline**: `NsoClient` (Nintendo auth) → `SplatNet3Client` (GraphQL queries) → DataUpdaters (`app/data/updaters/`) → JSON files in `dist/data/` → Frontend Pinia stores → Vue components. Images are processed via sharp. Data is optionally archived and synced to S3. -**Social media**: StatusGenerators (`app/social/generators/`) create content from data. Clients (`app/social/clients/`) post to each platform. Screenshots are generated via puppeteer-core + a browserless service. +**Social media**: StatusGenerators (`app/social/generators/`) create content from data. Clients (`app/social/clients/`) post to each platform. Production screenshots use Cloudflare Browser Run Quick Actions against the public site; local development can use puppeteer-core + Browserless. **Scheduling**: Cron jobs (`app/cron.mjs`) run data updates and social posting at intervals. @@ -56,4 +56,4 @@ Tests use Vitest. Test files live alongside source: `app/**/*.test.mjs` and `src - Frontend: built to `dist/` and deployed to AWS S3 (static hosting) - Backend: Docker container (`docker/app/Dockerfile`) pushed to GitHub Container Registry - `dist/` is not emptied on build (preserves generated `dist/data/` from backend) -- Browserless runs as a separate Docker service for screenshot generation +- Browserless runs as a separate Docker service for local screenshot generation diff --git a/app/screenshots/ScreenshotHelper.mjs b/app/screenshots/ScreenshotHelper.mjs index 710ff2d..bc70830 100644 --- a/app/screenshots/ScreenshotHelper.mjs +++ b/app/screenshots/ScreenshotHelper.mjs @@ -11,6 +11,8 @@ const defaultViewport = { export default class ScreenshotHelper { + _provider = null; + _fetch; /** @type {HttpServer} */ _httpServer = null; /** @type {puppeteer.Browser} */ @@ -20,8 +22,12 @@ export default class ScreenshotHelper defaultParams = null; + constructor({ fetch = globalThis.fetch } = {}) { + this._fetch = fetch; + } + get isOpen() { - return !!this._browser; + return !!this._provider; } /** @type {puppeteer.Page} */ @@ -32,6 +38,25 @@ export default class ScreenshotHelper async open() { await this.close(); + let provider = process.env.SCREENSHOT_PROVIDER; + + if (provider === 'cloudflare') { + this._requireConfiguration([ + 'SITE_URL', + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_BROWSER_RUN_API_TOKEN', + ], 'Cloudflare screenshot'); + this._provider = provider; + return; + } + + if (provider !== 'browserless') { + throw new Error('SCREENSHOT_PROVIDER must be "cloudflare" or "browserless"'); + } + + this._requireConfiguration(['BROWSERLESS_ENDPOINT'], 'Browserless screenshot'); + this._provider = provider; + // Start the HTTP server this._httpServer = new HttpServer; await this._httpServer.open(); @@ -46,6 +71,13 @@ export default class ScreenshotHelper await this.applyViewport(); } + _requireConfiguration(names, label) { + let missing = names.filter(name => !process.env[name]); + if (missing.length) { + throw new Error(`Missing ${label} configuration: ${missing.join(', ')}`); + } + } + async applyViewport(viewport = {}) { if (this._page) { await this._page.setViewport({ @@ -60,11 +92,14 @@ export default class ScreenshotHelper await this.open(); } - await this.applyViewport(options.viewport); - // Navigate to the URL - let host = process.env.SCREENSHOT_HOST || 'localhost'; - let url = new URL(`http://${host}:${this._httpServer.port}/screenshots/`); + let url; + if (this._provider === 'cloudflare') { + url = new URL('/screenshots/', process.env.SITE_URL); + } else { + let host = process.env.SCREENSHOT_HOST || 'localhost'; + url = new URL(`http://${host}:${this._httpServer.port}/screenshots/`); + } url.hash = path; let params = { @@ -80,6 +115,17 @@ export default class ScreenshotHelper .join('&'); } + let viewport = { + ...defaultViewport, + ...options.viewport, + }; + + if (this._provider === 'cloudflare') { + return await this._captureCloudflare(url, viewport); + } + + await this.applyViewport(viewport); + await this._page.goto(url, { waitUntil: 'networkidle0', // Wait until the network is idle }); @@ -91,6 +137,52 @@ export default class ScreenshotHelper return await this._page.screenshot(); } + async _captureCloudflare(url, viewport) { + let endpoint = new URL( + `/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/browser-rendering/screenshot`, + 'https://api.cloudflare.com', + ); + endpoint.searchParams.set('cacheTTL', '0'); + + let response = await this._fetch(endpoint.toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: url.toString(), + viewport, + gotoOptions: { waitUntil: 'networkidle0' }, + waitForTimeout: 1000, + screenshotOptions: { type: 'png' }, + }), + }); + + if (!response.ok) { + let message = await this._cloudflareErrorMessage(response); + throw new Error(`Cloudflare Browser Run screenshot failed (${response.status}): ${message}`); + } + + return Buffer.from(await response.arrayBuffer()); + } + + async _cloudflareErrorMessage(response) { + let body = await response.text(); + + try { + let result = JSON.parse(body); + let messages = result.errors?.map(error => error.message).filter(Boolean); + if (messages?.length) { + return messages.join('; '); + } + } catch { + // Use the response body as-is when Cloudflare does not return JSON. + } + + return body || response.statusText || 'Unknown error'; + } + async close() { if (this._httpServer) { await this._httpServer.close(); @@ -106,5 +198,6 @@ export default class ScreenshotHelper await this._browser.close(); } this._browser = null; + this._provider = null; } } diff --git a/app/screenshots/ScreenshotHelper.test.mjs b/app/screenshots/ScreenshotHelper.test.mjs new file mode 100644 index 0000000..0d6efab --- /dev/null +++ b/app/screenshots/ScreenshotHelper.test.mjs @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ScreenshotHelper from './ScreenshotHelper.mjs'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('ScreenshotHelper', () => { + it('captures a public screenshot through Cloudflare Browser Run', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'cloudflare'); + vi.stubEnv('SITE_URL', 'https://splatoon3.ink'); + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', 'account-id'); + vi.stubEnv('CLOUDFLARE_BROWSER_RUN_API_TOKEN', 'api-token'); + + let png = new Uint8Array([137, 80, 78, 71]); + let fetch = vi.fn().mockResolvedValue(new Response(png, { + headers: { 'Content-Type': 'image/png' }, + })); + let helper = new ScreenshotHelper({ fetch }); + helper.defaultParams = { time: 123 }; + + let screenshot = await helper.capture('schedules', { + params: { region: 'NA' }, + viewport: { width: 600 }, + }); + + expect(screenshot).toEqual(Buffer.from(png)); + expect(fetch).toHaveBeenCalledWith( + 'https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/screenshot?cacheTTL=0', + { + method: 'POST', + headers: { + Authorization: 'Bearer api-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: 'https://splatoon3.ink/screenshots/#schedules?time=123®ion=NA', + viewport: { + width: 600, + height: 675, + deviceScaleFactor: 2, + }, + gotoOptions: { waitUntil: 'networkidle0' }, + waitForTimeout: 1000, + screenshotOptions: { type: 'png' }, + }), + }, + ); + }); + + it('surfaces a Cloudflare rate limit response without retrying', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'cloudflare'); + vi.stubEnv('SITE_URL', 'https://splatoon3.ink'); + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', 'account-id'); + vi.stubEnv('CLOUDFLARE_BROWSER_RUN_API_TOKEN', 'api-token'); + + let fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + success: false, + errors: [{ code: 2001, message: 'Rate limit exceeded' }], + }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + })); + let helper = new ScreenshotHelper({ fetch }); + + await expect(helper.capture('schedules')).rejects.toThrow( + 'Cloudflare Browser Run screenshot failed (429): Rate limit exceeded', + ); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('requires the Cloudflare configuration before opening', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'cloudflare'); + vi.stubEnv('SITE_URL', ''); + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', ''); + vi.stubEnv('CLOUDFLARE_BROWSER_RUN_API_TOKEN', ''); + + let helper = new ScreenshotHelper; + + await expect(helper.open()).rejects.toThrow( + 'Missing Cloudflare screenshot configuration: SITE_URL, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_BROWSER_RUN_API_TOKEN', + ); + }); +}); diff --git a/app/social/StatusGeneratorManager.mjs b/app/social/StatusGeneratorManager.mjs index 2e6a0c4..a0fa68a 100644 --- a/app/social/StatusGeneratorManager.mjs +++ b/app/social/StatusGeneratorManager.mjs @@ -25,7 +25,7 @@ export default class StatusGeneratorManager async sendStatuses(force = false) { let availableClients = await this._getAvailableClients(); - // Create screenshots in parallel (via Browserless) + // Create screenshots in parallel let statusPromises = this._getStatuses(availableClients, force); // Process each client in parallel (while maintaining post order) diff --git a/docker-compose.override.yml.dev.example b/docker-compose.override.yml.dev.example index a663fc2..d5df252 100644 --- a/docker-compose.override.yml.dev.example +++ b/docker-compose.override.yml.dev.example @@ -8,6 +8,7 @@ services: init: true restart: unless-stopped environment: + SCREENSHOT_PROVIDER: browserless BROWSERLESS_ENDPOINT: ws://browserless:3000 SCREENSHOT_HOST: app depends_on: @@ -16,6 +17,11 @@ services: - .:/app browserless: + image: ghcr.io/browserless/chromium platform: linux/arm64 # Needed for Apple Silicon + restart: unless-stopped + environment: + CONCURRENT: ${BROWSERLESS_CONCURRENT:-1} + QUEUED: ${BROWSERLESS_QUEUED:-100} ports: - 3000:3000 diff --git a/docker-compose.override.yml.prod.example b/docker-compose.override.yml.prod.example index 97697de..877b21b 100644 --- a/docker-compose.override.yml.prod.example +++ b/docker-compose.override.yml.prod.example @@ -6,17 +6,11 @@ services: init: true restart: unless-stopped environment: - BROWSERLESS_ENDPOINT: ws://browserless:3000 - SCREENSHOT_HOST: app - depends_on: - - browserless + SCREENSHOT_PROVIDER: cloudflare env_file: - .env labels: [ "com.centurylinklabs.watchtower.scope=splatoon3ink" ] - browserless: - labels: [ "com.centurylinklabs.watchtower.scope=splatoon3ink" ] - watchtower: image: containrrr/watchtower volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 5091e6e..964dc66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,3 @@ # See docker-compose.override.yml.* example files for dev/prod environments -services: - browserless: - image: ghcr.io/browserless/chromium - restart: unless-stopped - environment: - CONCURRENT: ${BROWSERLESS_CONCURRENT:-1} - QUEUED: ${BROWSERLESS_QUEUED:-100} +services: {} diff --git a/readme.md b/readme.md index 4849a35..8f93d45 100644 --- a/readme.md +++ b/readme.md @@ -27,6 +27,12 @@ npm run dev npm run build ``` +### Screenshot Generation + +Set `SCREENSHOT_PROVIDER` explicitly for social-media screenshots. Use `browserless` for local development with the Docker Compose development configuration. Use `cloudflare` in production to call Cloudflare Browser Run Quick Actions against `${SITE_URL}/screenshots/`. + +The Cloudflare provider requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_BROWSER_RUN_API_TOKEN`. Create the API token with the **Browser Rendering Write** permission. The provider does not automatically fall back to Browserless when a Cloudflare request fails. + ### Lint with [ESLint](https://eslint.org/) ```sh From c2ff22fe0fe14545067eef8941f9ac131b9b20a1 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 29 Aug 2026 19:23:48 -0700 Subject: [PATCH 2/5] Extract screenshot drivers --- CLAUDE.md | 2 +- app/screenshots/ScreenshotHelper.mjs | 177 +++--------------- app/screenshots/ScreenshotHelper.test.mjs | 66 +++++++ .../drivers/BrowserlessScreenshotDriver.mjs | 68 +++++++ .../drivers/CloudflareScreenshotDriver.mjs | 84 +++++++++ .../drivers/createScreenshotDriver.mjs | 14 ++ readme.md | 21 +++ 7 files changed, 276 insertions(+), 156 deletions(-) create mode 100644 app/screenshots/drivers/BrowserlessScreenshotDriver.mjs create mode 100644 app/screenshots/drivers/CloudflareScreenshotDriver.mjs create mode 100644 app/screenshots/drivers/createScreenshotDriver.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 2943b0d..cc81236 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ npm start # Full production: sync → splatnet → social → cron **Data pipeline**: `NsoClient` (Nintendo auth) → `SplatNet3Client` (GraphQL queries) → DataUpdaters (`app/data/updaters/`) → JSON files in `dist/data/` → Frontend Pinia stores → Vue components. Images are processed via sharp. Data is optionally archived and synced to S3. -**Social media**: StatusGenerators (`app/social/generators/`) create content from data. Clients (`app/social/clients/`) post to each platform. Production screenshots use Cloudflare Browser Run Quick Actions against the public site; local development can use puppeteer-core + Browserless. +**Social media**: StatusGenerators (`app/social/generators/`) create content from data. Clients (`app/social/clients/`) post to each platform. `ScreenshotHelper` delegates rendering to drivers in `app/screenshots/drivers/`: production uses Cloudflare Browser Run Quick Actions against the public site, while local development can use puppeteer-core + Browserless. **Scheduling**: Cron jobs (`app/cron.mjs`) run data updates and social posting at intervals. diff --git a/app/screenshots/ScreenshotHelper.mjs b/app/screenshots/ScreenshotHelper.mjs index bc70830..79452cf 100644 --- a/app/screenshots/ScreenshotHelper.mjs +++ b/app/screenshots/ScreenshotHelper.mjs @@ -1,6 +1,4 @@ -import { URL } from 'url'; -import puppeteer from 'puppeteer-core'; -import HttpServer from './HttpServer.mjs'; +import createScreenshotDriver from './drivers/createScreenshotDriver.mjs'; const defaultViewport = { // Using a 16:9 ratio here by default to match Twitter's image card dimensions @@ -11,80 +9,29 @@ const defaultViewport = { export default class ScreenshotHelper { - _provider = null; - _fetch; - /** @type {HttpServer} */ - _httpServer = null; - /** @type {puppeteer.Browser} */ - _browser = null; - /** @type {puppeteer.Page} */ - _page = null; + _driver = null; + _driverDependencies; + _isOpen = false; defaultParams = null; - constructor({ fetch = globalThis.fetch } = {}) { - this._fetch = fetch; + constructor(driverDependencies = {}) { + this._driverDependencies = driverDependencies; } get isOpen() { - return !!this._provider; - } - - /** @type {puppeteer.Page} */ - get page() { - return this._page; + return this._isOpen; } async open() { await this.close(); - let provider = process.env.SCREENSHOT_PROVIDER; - - if (provider === 'cloudflare') { - this._requireConfiguration([ - 'SITE_URL', - 'CLOUDFLARE_ACCOUNT_ID', - 'CLOUDFLARE_BROWSER_RUN_API_TOKEN', - ], 'Cloudflare screenshot'); - this._provider = provider; - return; - } - - if (provider !== 'browserless') { - throw new Error('SCREENSHOT_PROVIDER must be "cloudflare" or "browserless"'); - } - - this._requireConfiguration(['BROWSERLESS_ENDPOINT'], 'Browserless screenshot'); - this._provider = provider; - - // Start the HTTP server - this._httpServer = new HttpServer; - await this._httpServer.open(); - - // Connect to Browserless - this._browser = await puppeteer.connect({ - browserWSEndpoint: process.env.BROWSERLESS_ENDPOINT, - }); - - // Create a new page and set the viewport - this._page = await this._browser.newPage(); - await this.applyViewport(); - } - - _requireConfiguration(names, label) { - let missing = names.filter(name => !process.env[name]); - if (missing.length) { - throw new Error(`Missing ${label} configuration: ${missing.join(', ')}`); - } - } - - async applyViewport(viewport = {}) { - if (this._page) { - await this._page.setViewport({ - ...defaultViewport, - ...viewport, - }); - } + this._driver = createScreenshotDriver( + process.env.SCREENSHOT_PROVIDER, + this._driverDependencies, + ); + await this._driver.open(); + this._isOpen = true; } async capture(path, options = {}) { @@ -92,25 +39,15 @@ export default class ScreenshotHelper await this.open(); } - // Navigate to the URL - let url; - if (this._provider === 'cloudflare') { - url = new URL('/screenshots/', process.env.SITE_URL); - } else { - let host = process.env.SCREENSHOT_HOST || 'localhost'; - url = new URL(`http://${host}:${this._httpServer.port}/screenshots/`); - } - url.hash = path; - let params = { ...this.defaultParams, ...options.params, }; + let route = path; - if (params) { - // We can't use url.searchParams because they need to come after the hash - url.hash += '?'; - url.hash += Object.keys(params) + if (Object.keys(params).length) { + route += '?'; + route += Object.keys(params) .map(key => `${key}=${params[key]}`) .join('&'); } @@ -120,84 +57,14 @@ export default class ScreenshotHelper ...options.viewport, }; - if (this._provider === 'cloudflare') { - return await this._captureCloudflare(url, viewport); - } - - await this.applyViewport(viewport); - - await this._page.goto(url, { - waitUntil: 'networkidle0', // Wait until the network is idle - }); - - // Wait an additional 1000ms - await this._page.waitForNetworkIdle({ idleTime: 1000 }); - - // Take the screenshot - return await this._page.screenshot(); - } - - async _captureCloudflare(url, viewport) { - let endpoint = new URL( - `/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/browser-rendering/screenshot`, - 'https://api.cloudflare.com', - ); - endpoint.searchParams.set('cacheTTL', '0'); - - let response = await this._fetch(endpoint.toString(), { - method: 'POST', - headers: { - Authorization: `Bearer ${process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - url: url.toString(), - viewport, - gotoOptions: { waitUntil: 'networkidle0' }, - waitForTimeout: 1000, - screenshotOptions: { type: 'png' }, - }), - }); - - if (!response.ok) { - let message = await this._cloudflareErrorMessage(response); - throw new Error(`Cloudflare Browser Run screenshot failed (${response.status}): ${message}`); - } - - return Buffer.from(await response.arrayBuffer()); - } - - async _cloudflareErrorMessage(response) { - let body = await response.text(); - - try { - let result = JSON.parse(body); - let messages = result.errors?.map(error => error.message).filter(Boolean); - if (messages?.length) { - return messages.join('; '); - } - } catch { - // Use the response body as-is when Cloudflare does not return JSON. - } - - return body || response.statusText || 'Unknown error'; + return await this._driver.capture(route, viewport); } async close() { - if (this._httpServer) { - await this._httpServer.close(); + if (this._driver) { + await this._driver.close(); } - this._httpServer = null; - - if (this._page) { - await this._page.close(); - } - this._page = null; - - if (this._browser) { - await this._browser.close(); - } - this._browser = null; - this._provider = null; + this._driver = null; + this._isOpen = false; } } diff --git a/app/screenshots/ScreenshotHelper.test.mjs b/app/screenshots/ScreenshotHelper.test.mjs index 0d6efab..527f85d 100644 --- a/app/screenshots/ScreenshotHelper.test.mjs +++ b/app/screenshots/ScreenshotHelper.test.mjs @@ -6,6 +6,62 @@ afterEach(() => { }); describe('ScreenshotHelper', () => { + it('captures a local screenshot through Browserless', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'browserless'); + + let png = Buffer.from([137, 80, 78, 71]); + let page = { + setViewport: vi.fn(), + goto: vi.fn(), + waitForNetworkIdle: vi.fn(), + screenshot: vi.fn().mockResolvedValue(png), + close: vi.fn(), + }; + let browser = { + newPage: vi.fn().mockResolvedValue(page), + close: vi.fn(), + }; + let puppeteerClient = { + connect: vi.fn().mockResolvedValue(browser), + }; + let httpServer = { + port: 4321, + open: vi.fn(), + close: vi.fn(), + }; + let helper = new ScreenshotHelper({ + env: { + BROWSERLESS_ENDPOINT: 'ws://browserless:3000', + SCREENSHOT_HOST: 'app', + }, + httpServerFactory: () => httpServer, + puppeteerClient, + }); + + let screenshot = await helper.capture('schedules', { + viewport: { height: 400 }, + }); + await helper.close(); + + expect(screenshot).toEqual(png); + expect(puppeteerClient.connect).toHaveBeenCalledWith({ + browserWSEndpoint: 'ws://browserless:3000', + }); + expect(page.setViewport).toHaveBeenCalledWith({ + width: 1200, + height: 400, + deviceScaleFactor: 2, + }); + expect(page.goto).toHaveBeenCalledWith( + new URL('http://app:4321/screenshots/#schedules'), + { waitUntil: 'networkidle0' }, + ); + expect(page.waitForNetworkIdle).toHaveBeenCalledWith({ idleTime: 1000 }); + expect(httpServer.close).toHaveBeenCalledOnce(); + expect(page.close).toHaveBeenCalledOnce(); + expect(browser.close).toHaveBeenCalledOnce(); + }); + it('captures a public screenshot through Cloudflare Browser Run', async () => { vi.stubEnv('SCREENSHOT_PROVIDER', 'cloudflare'); vi.stubEnv('SITE_URL', 'https://splatoon3.ink'); @@ -81,4 +137,14 @@ describe('ScreenshotHelper', () => { 'Missing Cloudflare screenshot configuration: SITE_URL, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_BROWSER_RUN_API_TOKEN', ); }); + + it('requires an explicitly supported screenshot provider', async () => { + vi.stubEnv('SCREENSHOT_PROVIDER', 'auto'); + + let helper = new ScreenshotHelper; + + await expect(helper.open()).rejects.toThrow( + 'SCREENSHOT_PROVIDER must be "cloudflare" or "browserless"', + ); + }); }); diff --git a/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs b/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs new file mode 100644 index 0000000..4e61981 --- /dev/null +++ b/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs @@ -0,0 +1,68 @@ +import { URL } from 'url'; +import puppeteer from 'puppeteer-core'; +import HttpServer from '../HttpServer.mjs'; + +export default class BrowserlessScreenshotDriver +{ + _browser = null; + _env; + _httpServer = null; + _httpServerFactory; + _page = null; + _puppeteer; + + constructor({ + env = process.env, + httpServerFactory = () => new HttpServer, + puppeteerClient = puppeteer, + } = {}) { + this._env = env; + this._httpServerFactory = httpServerFactory; + this._puppeteer = puppeteerClient; + } + + async open() { + if (!this._env.BROWSERLESS_ENDPOINT) { + throw new Error('Missing Browserless screenshot configuration: BROWSERLESS_ENDPOINT'); + } + + this._httpServer = this._httpServerFactory(); + await this._httpServer.open(); + + this._browser = await this._puppeteer.connect({ + browserWSEndpoint: this._env.BROWSERLESS_ENDPOINT, + }); + this._page = await this._browser.newPage(); + } + + async capture(route, viewport) { + let host = this._env.SCREENSHOT_HOST || 'localhost'; + let url = new URL(`http://${host}:${this._httpServer.port}/screenshots/`); + url.hash = route; + + await this._page.setViewport(viewport); + await this._page.goto(url, { + waitUntil: 'networkidle0', + }); + await this._page.waitForNetworkIdle({ idleTime: 1000 }); + + return await this._page.screenshot(); + } + + async close() { + if (this._httpServer) { + await this._httpServer.close(); + } + this._httpServer = null; + + if (this._page) { + await this._page.close(); + } + this._page = null; + + if (this._browser) { + await this._browser.close(); + } + this._browser = null; + } +} diff --git a/app/screenshots/drivers/CloudflareScreenshotDriver.mjs b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs new file mode 100644 index 0000000..1d4c8ed --- /dev/null +++ b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs @@ -0,0 +1,84 @@ +import { URL } from 'url'; + +export default class CloudflareScreenshotDriver +{ + _config = null; + _env; + _fetch; + + constructor({ env = process.env, fetch = globalThis.fetch } = {}) { + this._env = env; + this._fetch = fetch; + } + + async open() { + let names = [ + 'SITE_URL', + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_BROWSER_RUN_API_TOKEN', + ]; + let missing = names.filter(name => !this._env[name]); + if (missing.length) { + throw new Error(`Missing Cloudflare screenshot configuration: ${missing.join(', ')}`); + } + + this._config = { + siteUrl: this._env.SITE_URL, + accountId: this._env.CLOUDFLARE_ACCOUNT_ID, + apiToken: this._env.CLOUDFLARE_BROWSER_RUN_API_TOKEN, + }; + } + + async capture(route, viewport) { + let url = new URL('/screenshots/', this._config.siteUrl); + url.hash = route; + + let endpoint = new URL( + `/client/v4/accounts/${this._config.accountId}/browser-rendering/screenshot`, + 'https://api.cloudflare.com', + ); + endpoint.searchParams.set('cacheTTL', '0'); + + let response = await this._fetch(endpoint.toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${this._config.apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: url.toString(), + viewport, + gotoOptions: { waitUntil: 'networkidle0' }, + waitForTimeout: 1000, + screenshotOptions: { type: 'png' }, + }), + }); + + if (!response.ok) { + let message = await this._errorMessage(response); + throw new Error(`Cloudflare Browser Run screenshot failed (${response.status}): ${message}`); + } + + return Buffer.from(await response.arrayBuffer()); + } + + async _errorMessage(response) { + let body = await response.text(); + + try { + let result = JSON.parse(body); + let messages = result.errors?.map(error => error.message).filter(Boolean); + if (messages?.length) { + return messages.join('; '); + } + } catch { + // Use the response body as-is when Cloudflare does not return JSON. + } + + return body || response.statusText || 'Unknown error'; + } + + async close() { + this._config = null; + } +} diff --git a/app/screenshots/drivers/createScreenshotDriver.mjs b/app/screenshots/drivers/createScreenshotDriver.mjs new file mode 100644 index 0000000..b4c9259 --- /dev/null +++ b/app/screenshots/drivers/createScreenshotDriver.mjs @@ -0,0 +1,14 @@ +import BrowserlessScreenshotDriver from './BrowserlessScreenshotDriver.mjs'; +import CloudflareScreenshotDriver from './CloudflareScreenshotDriver.mjs'; + +export default function createScreenshotDriver(name, dependencies = {}) { + if (name === 'browserless') { + return new BrowserlessScreenshotDriver(dependencies); + } + + if (name === 'cloudflare') { + return new CloudflareScreenshotDriver(dependencies); + } + + throw new Error('SCREENSHOT_PROVIDER must be "cloudflare" or "browserless"'); +} diff --git a/readme.md b/readme.md index 8f93d45..dd7aaa3 100644 --- a/readme.md +++ b/readme.md @@ -33,6 +33,27 @@ Set `SCREENSHOT_PROVIDER` explicitly for social-media screenshots. Use `browserl The Cloudflare provider requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_BROWSER_RUN_API_TOKEN`. Create the API token with the **Browser Rendering Write** permission. The provider does not automatically fall back to Browserless when a Cloudflare request fails. +Wrangler can run a local browser for Puppeteer, Playwright, and CDP-based Workers, but Quick Actions are not supported by its local browser binding. Quick Actions require remote mode, so testing this provider still requires Cloudflare to reach the rendered page. + +To test the real Cloudflare provider against a local build, build and serve `dist` in one terminal: + +```sh +npm run build +npm run preview +``` + +Expose that server through a temporary Wrangler tunnel in a second terminal: + +```sh +npx wrangler@latest tunnel quick-start http://localhost:5050 +``` + +In a third terminal, use the printed `https://*.trycloudflare.com` URL for that run: + +```sh +SCREENSHOT_PROVIDER=cloudflare SITE_URL=https://example.trycloudflare.com npm run social:test +``` + ### Lint with [ESLint](https://eslint.org/) ```sh From 79f6232abebd78b2c1a884b833c333dce2c4602d Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 29 Aug 2026 22:28:49 -0700 Subject: [PATCH 3/5] Fix Cloudflare screenshot page URL --- app/screenshots/drivers/CloudflareScreenshotDriver.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/screenshots/drivers/CloudflareScreenshotDriver.mjs b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs index 1d4c8ed..d60e0ac 100644 --- a/app/screenshots/drivers/CloudflareScreenshotDriver.mjs +++ b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs @@ -30,7 +30,7 @@ export default class CloudflareScreenshotDriver } async capture(route, viewport) { - let url = new URL('/screenshots/', this._config.siteUrl); + let url = new URL('/screenshots/index.html', this._config.siteUrl); url.hash = route; let endpoint = new URL( From 9dff1c01dc8b43c9b51115b95072a07ad4f6c6fb Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sat, 29 Aug 2026 22:34:20 -0700 Subject: [PATCH 4/5] Wait for screenshot page readiness --- app/screenshots/ScreenshotHelper.test.mjs | 18 +++-- .../drivers/BrowserlessScreenshotDriver.mjs | 7 +- .../drivers/CloudflareScreenshotDriver.mjs | 8 ++- src/common/screenshot.mjs | 24 +++++++ src/common/screenshot.test.mjs | 66 +++++++++++++++++++ src/layouts/ScreenshotLayout.vue | 25 ++++++- src/stores/data.mjs | 2 + src/stores/data.test.mjs | 29 ++++++++ 8 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 src/common/screenshot.mjs create mode 100644 src/common/screenshot.test.mjs create mode 100644 src/stores/data.test.mjs diff --git a/app/screenshots/ScreenshotHelper.test.mjs b/app/screenshots/ScreenshotHelper.test.mjs index 527f85d..5913c5c 100644 --- a/app/screenshots/ScreenshotHelper.test.mjs +++ b/app/screenshots/ScreenshotHelper.test.mjs @@ -13,7 +13,7 @@ describe('ScreenshotHelper', () => { let page = { setViewport: vi.fn(), goto: vi.fn(), - waitForNetworkIdle: vi.fn(), + waitForSelector: vi.fn(), screenshot: vi.fn().mockResolvedValue(png), close: vi.fn(), }; @@ -54,9 +54,12 @@ describe('ScreenshotHelper', () => { }); expect(page.goto).toHaveBeenCalledWith( new URL('http://app:4321/screenshots/#schedules'), - { waitUntil: 'networkidle0' }, + { waitUntil: 'load' }, + ); + expect(page.waitForSelector).toHaveBeenCalledWith( + '[data-screenshot-ready="true"]', + { timeout: 30_000 }, ); - expect(page.waitForNetworkIdle).toHaveBeenCalledWith({ idleTime: 1000 }); expect(httpServer.close).toHaveBeenCalledOnce(); expect(page.close).toHaveBeenCalledOnce(); expect(browser.close).toHaveBeenCalledOnce(); @@ -90,14 +93,17 @@ describe('ScreenshotHelper', () => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - url: 'https://splatoon3.ink/screenshots/#schedules?time=123®ion=NA', + url: 'https://splatoon3.ink/screenshots/index.html#schedules?time=123®ion=NA', viewport: { width: 600, height: 675, deviceScaleFactor: 2, }, - gotoOptions: { waitUntil: 'networkidle0' }, - waitForTimeout: 1000, + gotoOptions: { waitUntil: 'load' }, + waitForSelector: { + selector: '[data-screenshot-ready="true"]', + timeout: 30_000, + }, screenshotOptions: { type: 'png' }, }), }, diff --git a/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs b/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs index 4e61981..03bab9f 100644 --- a/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs +++ b/app/screenshots/drivers/BrowserlessScreenshotDriver.mjs @@ -1,5 +1,6 @@ import { URL } from 'url'; import puppeteer from 'puppeteer-core'; +import { screenshotReadySelector, screenshotReadyTimeout } from '../../../src/common/screenshot.mjs'; import HttpServer from '../HttpServer.mjs'; export default class BrowserlessScreenshotDriver @@ -42,9 +43,11 @@ export default class BrowserlessScreenshotDriver await this._page.setViewport(viewport); await this._page.goto(url, { - waitUntil: 'networkidle0', + waitUntil: 'load', + }); + await this._page.waitForSelector(screenshotReadySelector, { + timeout: screenshotReadyTimeout, }); - await this._page.waitForNetworkIdle({ idleTime: 1000 }); return await this._page.screenshot(); } diff --git a/app/screenshots/drivers/CloudflareScreenshotDriver.mjs b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs index d60e0ac..feaae3f 100644 --- a/app/screenshots/drivers/CloudflareScreenshotDriver.mjs +++ b/app/screenshots/drivers/CloudflareScreenshotDriver.mjs @@ -1,4 +1,5 @@ import { URL } from 'url'; +import { screenshotReadySelector, screenshotReadyTimeout } from '../../../src/common/screenshot.mjs'; export default class CloudflareScreenshotDriver { @@ -48,8 +49,11 @@ export default class CloudflareScreenshotDriver body: JSON.stringify({ url: url.toString(), viewport, - gotoOptions: { waitUntil: 'networkidle0' }, - waitForTimeout: 1000, + gotoOptions: { waitUntil: 'load' }, + waitForSelector: { + selector: screenshotReadySelector, + timeout: screenshotReadyTimeout, + }, screenshotOptions: { type: 'png' }, }), }); diff --git a/src/common/screenshot.mjs b/src/common/screenshot.mjs new file mode 100644 index 0000000..ef28323 --- /dev/null +++ b/src/common/screenshot.mjs @@ -0,0 +1,24 @@ +export const screenshotReadyAttribute = 'data-screenshot-ready'; +export const screenshotReadySelector = `[${screenshotReadyAttribute}="true"]`; +export const screenshotReadyTimeout = 30_000; + +function nextFrame(requestAnimationFrame) { + return new Promise(resolve => requestAnimationFrame(resolve)); +} + +export async function markScreenshotReady({ + document = globalThis.document, + isCurrent = () => true, + requestAnimationFrame = globalThis.requestAnimationFrame, +} = {}) { + await document.fonts?.ready; + await Promise.allSettled( + [...document.images].map(image => image.decode()), + ); + await nextFrame(requestAnimationFrame); + await nextFrame(requestAnimationFrame); + + if (isCurrent()) { + document.documentElement.setAttribute(screenshotReadyAttribute, 'true'); + } +} diff --git a/src/common/screenshot.test.mjs b/src/common/screenshot.test.mjs new file mode 100644 index 0000000..d28ff4f --- /dev/null +++ b/src/common/screenshot.test.mjs @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { markScreenshotReady } from './screenshot.mjs'; + +describe('markScreenshotReady', () => { + it('marks the page ready after fonts, images, and layout settle', async () => { + let resolveFonts; + let resolveImage; + let fontsReady = new Promise(resolve => { resolveFonts = resolve; }); + let imageReady = new Promise(resolve => { resolveImage = resolve; }); + let setAttribute = vi.fn(); + let document = { + documentElement: { setAttribute }, + fonts: { ready: fontsReady }, + images: [{ complete: true, decode: () => imageReady }], + }; + let frames = []; + let requestAnimationFrame = callback => frames.push(callback); + + let ready = markScreenshotReady({ document, requestAnimationFrame }); + await Promise.resolve(); + expect(setAttribute).not.toHaveBeenCalled(); + + resolveFonts(); + await Promise.resolve(); + expect(setAttribute).not.toHaveBeenCalled(); + + resolveImage(); + await Promise.resolve(); + await Promise.resolve(); + expect(frames).toHaveLength(1); + expect(setAttribute).not.toHaveBeenCalled(); + + frames.shift()(); + await Promise.resolve(); + expect(frames).toHaveLength(1); + expect(setAttribute).not.toHaveBeenCalled(); + + frames.shift()(); + await ready; + + expect(setAttribute).toHaveBeenCalledWith('data-screenshot-ready', 'true'); + }); + + it('does not mark a readiness run that became stale while assets settled', async () => { + let resolveImage; + let imageReady = new Promise(resolve => { resolveImage = resolve; }); + let setAttribute = vi.fn(); + let document = { + documentElement: { setAttribute }, + fonts: { ready: Promise.resolve() }, + images: [{ decode: () => imageReady }], + }; + let isCurrent = true; + let ready = markScreenshotReady({ + document, + isCurrent: () => isCurrent, + requestAnimationFrame: callback => callback(), + }); + + isCurrent = false; + resolveImage(); + await ready; + + expect(setAttribute).not.toHaveBeenCalled(); + }); +}); diff --git a/src/layouts/ScreenshotLayout.vue b/src/layouts/ScreenshotLayout.vue index 8f2c67e..186e6d2 100644 --- a/src/layouts/ScreenshotLayout.vue +++ b/src/layouts/ScreenshotLayout.vue @@ -40,8 +40,10 @@