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