Use Browser binding for screenshots and local Wrangler testing

This commit is contained in:
Matt Isenhower
2026-09-07 18:06:15 -07:00
parent a1e934d0bd
commit 5deac8c8cb
16 changed files with 174 additions and 191 deletions

View File

@@ -12,13 +12,9 @@ VUE_APP_GOOGLE_ANALYTICS_ID=
# (Optional) Sentry error reporting (https://sentry.io)
SENTRY_DSN=
# Node screenshot provider: puppeteer (local Chrome) or cloudflare (Browser Run API)
SCREENSHOT_PROVIDER=puppeteer
# Leave empty to serve dist/ temporarily with Puppeteer; set a dev/deployed URL otherwise.
# Cloudflare requires a URL it can reach. The Worker selects Cloudflare directly.
SITE_URL=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_BROWSER_RUN_API_TOKEN=
# (Optional) Bluesky API parameters
BLUESKY_SERVICE=https://bsky.social

View File

@@ -40,7 +40,8 @@
"social": "node src/app/index.js social",
"social:test": "node src/app/index.js socialTest",
"admin:preview": "node workers/updater/preview/server.mjs",
"screenshot": "node src/app/screenshots/cli.js"
"screenshot": "node src/app/screenshots/cli.js",
"screenshot:cloudflare": "wrangler dev --config workers/screenshots/wrangler.jsonc"
},
"dependencies": {
"@atproto/api": "^0.20.42",

View File

@@ -1,5 +1,4 @@
import { logMessage } from '../log.js';
import { fetchWithTimeout } from '../../common/fetch.js';
async function errorMessage(response) {
let body = await response.text();
@@ -18,53 +17,28 @@ async function errorMessage(response) {
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;
// Browser Run captures remotely through the Worker's Browser binding.
export default class BrowserRunRenderer {
constructor(browser) {
this.browser = browser;
}
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 options = {
url: url.toString(),
viewport,
cacheTTL: 0,
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);
let response = await this.browser.quickAction('screenshot', options);
if (!response.ok) {
let message = await errorMessage(response);

View File

@@ -11,7 +11,6 @@ if (existsSync('.env'))
try {
let { values } = parseArgs({
options: {
provider: { type: 'string' },
url: { type: 'string' },
hash: { type: 'string' },
output: { type: 'string', default: 'dist/test-screenshots/capture.png' },
@@ -21,10 +20,10 @@ try {
if (values.help) {
console.log(
'Usage: npm run screenshot -- [--provider puppeteer|cloudflare] (--url <page URL> | --hash <screenshot route>) [--output <file.png>]',
'Usage: npm run screenshot -- (--url <page URL> | --hash <screenshot route>) [--output <file.png>]',
);
console.log(
'Uses SCREENSHOT_PROVIDER and SITE_URL from the environment or .env. Without SITE_URL, Puppeteer serves dist/ temporarily.',
'Uses local Puppeteer and SITE_URL from the environment or .env. Without SITE_URL, Puppeteer serves dist/ temporarily.',
);
} else {
if (!!values.url === !!values.hash)

View File

@@ -1,32 +1,15 @@
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.
// The Node commands use local Chrome; Browser Run is tested through Wrangler.
export async function withScreenshots(
callback,
{ provider = process.env.SCREENSHOT_PROVIDER, siteUrl = process.env.SITE_URL, url } = {},
{ 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 renderer = new PuppeteerRenderer();
let server;
try {

View File

@@ -60,46 +60,6 @@ test('local browser uses page readiness and closes on success or failure', async
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');
@@ -129,7 +89,7 @@ test('temporary dist server is loopback-only and closes when capture fails', asy
return screenshots.capture({ hash: '/schedules/3600' });
},
{ provider: 'puppeteer', siteUrl: '' },
{ siteUrl: '' },
),
/Capture failed/,
);

View File

@@ -1,16 +1,12 @@
import { test, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import BrowserRunClient from '../../src/app/screenshots/BrowserRunClient.js';
import BrowserRunRenderer from '../../src/app/screenshots/BrowserRunRenderer.js';
import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js';
let browser;
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,
);
return new ScreenshotGenerator(new BrowserRunRenderer(browser), process.env.SITE_URL);
}
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
@@ -20,19 +16,19 @@ function fakeBrowserRendering(
) {
const requests = [];
mock.method(globalThis, 'fetch', async (input, init) => {
requests.push({ url: new URL(input), headers: new Headers(init.headers), body: JSON.parse(init.body) });
browser = {
async quickAction(action, body) {
requests.push({ action, body });
return respond();
});
return respond();
},
};
return requests;
}
beforeEach(() => {
process.env.SITE_URL = 'https://example.test';
process.env.CLOUDFLARE_ACCOUNT_ID = 'acct';
process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token';
});
afterEach(() => mock.restoreAll());
@@ -42,13 +38,10 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie
assert.equal(requests.length, 1);
const [{ url, headers, body }] = requests;
const [{ action, body }] = requests;
assert.equal(
url.href,
'https://api.cloudflare.com/client/v4/accounts/acct/browser-rendering/screenshot?cacheTTL=0',
);
assert.equal(headers.get('authorization'), 'Bearer token');
assert.equal(action, 'screenshot');
assert.equal(body.cacheTTL, 0);
assert.equal(body.url, 'https://example.test/screenshots.html#/schedules/3600');
assert.deepEqual(body.viewport, { width: 1216, height: 684, deviceScaleFactor: 2 });
assert.deepEqual(body.gotoOptions, { waitUntil: 'domcontentloaded', timeout: 10_000 });
@@ -88,15 +81,6 @@ test('reports API errors with Cloudflare\'s message', async () => {
);
});
test('fails clearly when configuration is missing', async () => {
delete process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN;
await assert.rejects(
screenshots().capture({ hash: '/x' }),
/Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/,
);
});
function immediateBackoff() {
const delays = [];
@@ -152,7 +136,7 @@ test('does not retry authentication, rate limits or non-timeout validation error
assert.deepEqual(delays, []);
});
test('retries network and client deadline failures, including while reading the image', async () => {
test('retries network and timeout failures, including while reading the image', async () => {
immediateBackoff();
let attempt = 0;

View File

@@ -0,0 +1,34 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import worker from '../../workers/screenshots/index.mjs';
test('capture-only Worker returns PNG bytes using the shared renderer', async () => {
let image = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
let url = 'https://example.test/screenshots.html#/schedules/3600';
let request = new Request(`http://localhost/?${new URLSearchParams({ url })}`);
let env = {
BROWSER: {
async quickAction(action, options) {
assert.equal(action, 'screenshot');
assert.equal(options.url, url);
assert.equal(options.waitForSelector.selector, '[data-screenshot-ready="true"]');
return new Response(image);
},
},
};
let response = await worker.fetch(request, env);
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type'), 'image/png');
assert.deepEqual(new Uint8Array(await response.arrayBuffer()), image);
});
test('capture-only Worker rejects missing and invalid targets before using the browser', async () => {
for (let url of ['', '?url=invalid', '?url=file:///tmp/page.html']) {
let response = await worker.fetch(new Request(`http://localhost/${url}`), {});
assert.equal(response.status, 400);
}
});

View File

@@ -0,0 +1,37 @@
import BrowserRunRenderer from '../../src/app/screenshots/BrowserRunRenderer.js';
import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js';
// Local capture-only entry point: no scheduler, buckets, or social clients.
export default {
async fetch(request, env) {
let params = new URL(request.url).searchParams;
let url = params.get('url');
if (request.method !== 'GET')
return new Response('Use GET with a url query parameter.', { status: 405 });
try {
if (!url || !['https:', 'http:'].includes(new URL(url).protocol))
return new Response('Provide a full HTTP(S) screenshot page URL in ?url=.', { status: 400 });
} catch {
return new Response('Invalid screenshot page URL.', { status: 400 });
}
let screenshots = new ScreenshotGenerator(new BrowserRunRenderer(env.BROWSER));
try {
let result = await screenshots.capture({ url });
return new Response(result.image, {
headers: {
'Content-Type': result.type,
'Cache-Control': 'no-store',
},
});
} catch (error) {
console.error(error);
return new Response(error.message, { status: 502 });
}
},
};

View File

@@ -0,0 +1,11 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "splatoon2-ink-screenshot-preview",
"main": "index.mjs",
"compatibility_date": "2026-09-03",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": false,
"preview_urls": false,
"browser": { "binding": "BROWSER", "remote": true },
"dev": { "ip": "127.0.0.1", "port": 8789 }
}

View File

@@ -73,7 +73,7 @@ Visually check generated images before cutover.
Nintendo, Bluesky, and rendering-site checks have 30-second network deadlines,
including body consumption. Browser Rendering uses 10-second navigation, page-ready, and capture limits,
with a 40-second overall request deadline per attempt. It waits for
through the Browser binding. It waits for
`data-screenshot-ready="true"` after data, Vue rendering, fonts, images and layout
settle, instead of waiting for network idle. Deploy the updated screenshot page
before the Worker that requires this marker.
@@ -87,11 +87,10 @@ as successful social runs.
## Local screenshots and Browser Run testing
Node commands select `SCREENSHOT_PROVIDER=puppeteer` or `cloudflare`, matching
splat3's setting name. There is no implicit provider fallback. The Worker
constructs its `BrowserRunClient` directly and does not import Puppeteer.
`ScreenshotGenerator` owns the shared routes, viewport, and page-ready selector;
either renderer returns PNG bytes. No new package import conditions are used.
Node commands use Puppeteer; the Worker uses `BrowserRunRenderer` with its
`BROWSER` binding. `ScreenshotGenerator` owns the shared routes, viewport, and
page-ready selector; either renderer returns PNG bytes. No API token or account
ID is needed for screenshots, and no package import conditions select renderers.
With Puppeteer, leave `SITE_URL` empty to temporarily serve the built `dist/`
on loopback. Run `npm run build` first and provide the usual data/assets in
@@ -103,30 +102,38 @@ and uses 10-second navigation, readiness, and browser-protocol timeouts.
```sh
# Generate the social test images against the local build.
SCREENSHOT_PROVIDER=puppeteer SITE_URL= npm run social:test
SITE_URL= npm run social:test
# Capture one route; replace the timestamp with a rotation in your data.
SCREENSHOT_PROVIDER=puppeteer SITE_URL= npm run screenshot -- \
SITE_URL= npm run screenshot -- \
--hash '/schedules/1788652800' --output dist/test-screenshots/schedule.png
# Capture a running dev server directly.
npm run screenshot -- --provider puppeteer \
npm run screenshot -- \
--url 'http://127.0.0.1:8080/screenshots.html#/schedules/1788652800'
# Exercise the actual Browser Run client from Node against a reachable site.
npm run screenshot -- --provider cloudflare \
--url 'https://dev.splatoon2.ink/screenshots.html#/schedules/1788652800' \
--output dist/test-screenshots/cloudflare.png
# Start the capture-only Worker locally, with a remote Browser binding.
npm run screenshot:cloudflare
# In another terminal, save a capture from a publicly reachable screenshot page.
curl --fail-with-body --get 'http://127.0.0.1:8789/' \
--data-urlencode 'url=https://dev.splatoon2.ink/screenshots.html#/schedules/1788652800' \
--output /tmp/cloudflare.png
```
`npm run screenshot` is capture-only: it does not read social data, compare
published datasets, post messages, update checkpoints, or convert to JPEG.
It loads `.env`, accepts either `--url` or `--hash`, and saves a PNG. Cloudflare
still requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_BROWSER_RUN_API_TOKEN`,
and cannot reach localhost directly. `--url` needs no `SITE_URL`; `--hash` uses
`SITE_URL` (or Puppeteer's temporary server). All screenshot pages must provide
the readiness marker. The Worker's social pipeline retains its published-data
checks; this diagnostic command deliberately does not run that pipeline.
`npm run screenshot` loads `.env`, accepts either `--url` or `--hash`, and saves
a PNG using local Chrome. `--hash` uses `SITE_URL` or the temporary dist server.
`npm run screenshot:cloudflare` runs the small `workers/screenshots` entry point
through Wrangler. Sign in with `npx wrangler login` if needed. Only the browser
runs remotely; the capture endpoint listens on loopback port 8789. This entry
point is for local development, not deployment. Cloudflare cannot reach localhost:
use a deployed preview or tunnel for an unpublished frontend. Remote captures
use the account's Browser Run allowance.
Both paths are capture-only: no updates, social sends, checkpoints, dataset
comparisons, or JPEG conversion. All screenshot pages must provide the readiness
marker. The updater's social pipeline retains its published-data checks.
## Shadow testing and cutover
@@ -211,14 +218,12 @@ Access. The panel does not expose force-repost, pause, or resume controls.
Secrets: `NINTENDO_SESSION_ID_NA`, `NINTENDO_SESSION_ID_EU`,
`NINTENDO_SESSION_ID_JP`, optional `SPLATNET_USER_AGENT`, `RUN_TOKEN`,
`CLOUDFLARE_BROWSER_RUN_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, optional `SENTRY_DSN`, and at cutover
optional `SENTRY_DSN`, and at cutover
`BLUESKY_SERVICE`, `BLUESKY_IDENTIFIER`, `BLUESKY_PASSWORD`.
Use `wrangler secret put NAME --config workers/updater/wrangler.jsonc` for a
secret. `SITE_URL` is a non-secret var in the config. The account ID is stored
as a secret to keep this account identifier out of the public repository; it
is not an authentication credential. Before deploying this change, configure
`CLOUDFLARE_ACCOUNT_ID` with the secret command above.
secret. `SITE_URL` is a non-secret var in the config. Screenshots use the
`BROWSER` binding; no Browser Run API credentials are required.
For local development, use gitignored `workers/updater/.dev.vars`. The existing
shared code reads these values through Workers' populated `process.env`.
Sentry wrappers route shared updater errors to Sentry when `SENTRY_DSN` is set.

View File

@@ -25,6 +25,14 @@ function network({ down = false, renderFails = false, beforeRequest } = {}) {
let splatnet = fakeSplatNet();
let renders = [];
vi.spyOn(env.BROWSER, 'quickAction').mockImplementation(async (action, options) => {
renders.push(options);
return new Response(renderFails ? 'render failed' : new Uint8Array([1, 2]), {
status: renderFails ? 503 : 200,
});
});
vi.stubGlobal('fetch', async (input, init) => {
const url = new URL(input);
@@ -34,14 +42,6 @@ function network({ down = false, renderFails = false, beforeRequest } = {}) {
return object ? new Response(object.body) : new Response('missing', { status: 404 });
}
if (url.hostname === 'api.cloudflare.com') {
renders.push(JSON.parse(init.body));
return new Response(renderFails ? 'render failed' : new Uint8Array([1, 2]), {
status: renderFails ? 503 : 200,
});
}
await beforeRequest?.();
return down ? new Response('down', { status: 503 }) : splatnet(input, init);
@@ -53,14 +53,13 @@ function network({ down = false, renderFails = false, beforeRequest } = {}) {
beforeEach(() => {
setSessionEnvironment();
process.env.SITE_URL = 'https://site.test';
process.env.CLOUDFLARE_ACCOUNT_ID = 'test';
process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'test';
for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD'])
delete process.env[name];
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});

View File

@@ -5,7 +5,7 @@ import { fakeSplatNet, ROUTES } from './fakeSplatNet.mjs';
import { getTopOfCurrentHour } from '../../src/common/time.js';
// The posters (src/app/social) running inside workerd: data from R2, screenshots from a
// stubbed Browser Rendering endpoint, no social credentials (shadow mode).
// stubbed Browser binding, no social credentials (shadow mode).
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
describe('runPosters', () => {
@@ -13,8 +13,6 @@ describe('runPosters', () => {
beforeEach(async () => {
process.env.SITE_URL = 'https://example.test';
process.env.CLOUDFLARE_ACCOUNT_ID = 'acct';
process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token';
for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD'])
delete process.env[name];
@@ -48,6 +46,12 @@ describe('runPosters', () => {
let splatnet = fakeSplatNet();
vi.spyOn(env.BROWSER, 'quickAction').mockImplementation(async (action, options) => {
renders.push(options);
return new Response(PNG, { headers: { 'content-type': 'image/png' } });
});
vi.stubGlobal('fetch', async (input, init) => {
let url = new URL(input);
@@ -57,16 +61,13 @@ describe('runPosters', () => {
return object ? new Response(object.body) : new Response('missing', { status: 404 });
}
if (url.hostname === 'api.cloudflare.com') {
renders.push(JSON.parse(init.body));
return new Response(PNG, { headers: { 'content-type': 'image/png' } });
}
return splatnet(input, init);
});
});
afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('renders the public images for the hour without posting anywhere', async () => {
let summary = await runPosters(env);

View File

@@ -1,4 +1,4 @@
import BrowserRunClient from '../../../src/app/screenshots/BrowserRunClient.js';
import BrowserRunRenderer from '../../../src/app/screenshots/BrowserRunRenderer.js';
import ScreenshotGenerator from '../../../src/app/screenshots/ScreenshotGenerator.js';
import stringify from 'json-stable-stringify';
import { sendStatuses, createClients } from '../../../src/app/social/index.js';
@@ -50,10 +50,7 @@ export async function runPosters(env) {
enabled.push(client.key);
let screenshots = new ScreenshotGenerator(
new BrowserRunClient({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN,
}),
new BrowserRunRenderer(env.BROWSER),
process.env.SITE_URL,
);
let result = await sendStatuses(storage, clients, screenshots);

View File

@@ -5,6 +5,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
cloudflareTest({
// Browser captures are mocked; automated tests never use the remote service.
remoteBindings: false,
wrangler: {
configPath: fileURLToPath(new URL('./wrangler.jsonc', import.meta.url)),
},

View File

@@ -30,10 +30,10 @@
],
"vars": {
// Screenshots for social posts are rendered from the deployed site by Browser Rendering.
// The API token is a secret (CLOUDFLARE_BROWSER_RUN_API_TOKEN).
"SITE_URL": "https://splatoon2.ink",
"ADMIN_HOSTNAME": "admin.dev.splatoon2.ink"
},
"browser": { "binding": "BROWSER", "remote": true },
"images": { "binding": "IMAGES" },
"r2_buckets": [
{