Add Cloudflare Browser Run screenshots

This commit is contained in:
Matt Isenhower
2026-08-29 19:09:22 -07:00
parent 5cda26d6ba
commit b62db8b149
9 changed files with 207 additions and 23 deletions

View File

@@ -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=

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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&region=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',
);
});
});

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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: {}

View File

@@ -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