mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-09-30 19:06:54 -05:00
Add Cloudflare Browser Run screenshots
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
84
app/screenshots/ScreenshotHelper.test.mjs
Normal file
84
app/screenshots/ScreenshotHelper.test.mjs
Normal 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®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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user