diff --git a/src/app/screenshots/screenshots.js b/src/app/screenshots/screenshots.js index 74562f7..bd35f4c 100644 --- a/src/app/screenshots/screenshots.js +++ b/src/app/screenshots/screenshots.js @@ -1,3 +1,5 @@ +import { fetchWithTimeout } from '../../common/fetch.js'; + // Screenshots of the site's screenshot page, rendered by Cloudflare Browser Rendering's REST // API. It is plain fetch, so the same code runs under Node and in a Worker. The page is the // deployed one (SITE_URL), which reads the published data. @@ -8,7 +10,7 @@ // CLOUDFLARE_BROWSER_RUN_API_TOKEN an API token with Browser Rendering permission const viewport = { - // Using a 16:9 ratio here by default to match Twitter's image card dimensions + // Use a 16:9 ratio for the public social image. // 1216 was chosen as the width because of Bulma's "widescreen" breakpoint width: 1216, height: 684, @@ -56,7 +58,7 @@ export async function captureScreenshot({ hash, viewport: viewportOverrides, for let endpoint = new URL(`/client/v4/accounts/${accountId}/browser-rendering/screenshot`, 'https://api.cloudflare.com'); endpoint.searchParams.set('cacheTTL', '0'); - let response = await fetch(endpoint, { + let response = await fetchWithTimeout(endpoint, { method: 'POST', headers: { 'Authorization': `Bearer ${apiToken}`, @@ -65,10 +67,12 @@ export async function captureScreenshot({ hash, viewport: viewportOverrides, for body: JSON.stringify({ url: url.toString(), viewport: thisViewport, - gotoOptions: { waitUntil: 'networkidle0' }, // Wait until the network is idle + gotoOptions: { waitUntil: 'networkidle0', timeout: 30_000 }, + actionTimeout: 30_000, + setExtraHTTPHeaders: { 'Cache-Control': 'no-cache' }, screenshotOptions: format === 'jpeg' ? { type: 'jpeg', quality: 90 } : { type: 'png' }, }), - }); + }, 90_000); if (!response.ok) throw new Error(`Browser Rendering screenshot failed (${response.status}): ${await errorMessage(response)}`); diff --git a/src/app/social/clients/BlueskyClient.js b/src/app/social/clients/BlueskyClient.js index 95d66a3..f32f74e 100644 --- a/src/app/social/clients/BlueskyClient.js +++ b/src/app/social/clients/BlueskyClient.js @@ -1,3 +1,4 @@ +import { fetchWithTimeout } from '../../../common/fetch.js'; import { BskyAgent, RichText } from '@atproto/api'; export default class BlueskyClient @@ -22,14 +23,16 @@ export default class BlueskyClient async login() { if (!this.#agent) { - this.#agent = new BskyAgent({ + let agent = new BskyAgent({ service: process.env.BLUESKY_SERVICE, + fetch: fetchWithTimeout, }); - await this.#agent.login({ + await agent.login({ identifier: process.env.BLUESKY_IDENTIFIER, password: process.env.BLUESKY_PASSWORD, }); + this.#agent = agent; } } diff --git a/src/common/fetch.js b/src/common/fetch.js new file mode 100644 index 0000000..6c00308 --- /dev/null +++ b/src/common/fetch.js @@ -0,0 +1,7 @@ +// A deadline covers both receiving headers and consuming the response body. +export function fetchWithTimeout(input, init = {}, timeoutMs = 30_000) { + let timeout = AbortSignal.timeout(timeoutMs); + let existing = init.signal ?? (input instanceof Request ? input.signal : null); + let signal = existing ? AbortSignal.any([existing, timeout]) : timeout; + return fetch(input, { ...init, signal }); +} diff --git a/src/common/splatnet.js b/src/common/splatnet.js index 02fcd1a..511de4e 100644 --- a/src/common/splatnet.js +++ b/src/common/splatnet.js @@ -1,3 +1,5 @@ +import { fetchWithTimeout } from './fetch.js'; + // SplatNet2 API const splatnetBaseUrl = 'https://app.splatoon2.nintendo.net'; @@ -29,7 +31,7 @@ export default class SplatNet { /** Fetch a path (or absolute URL) on the SplatNet site with the session cookie. */ async request(path, { headers = this.getHeaders() } = {}) { let url = new URL(path, splatnetBaseUrl); - let response = await fetch(url, { headers }); + let response = await fetchWithTimeout(url, { headers }); if (!response.ok) throw new Error(`SplatNet request failed with status ${response.status}: ${url.pathname}`); return response; diff --git a/test/fetch.test.js b/test/fetch.test.js new file mode 100644 index 0000000..0426503 --- /dev/null +++ b/test/fetch.test.js @@ -0,0 +1,45 @@ +import { test, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fetchWithTimeout } from '../src/common/fetch.js'; + +test('aborts a stalled request at its deadline', async () => { + mock.method(globalThis, 'fetch', (input, { signal }) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + })); + try { + await Promise.all([ + assert.rejects(fetchWithTimeout('https://example.test', {}, 5), { name: 'TimeoutError' }), + sleep(20), + ]); + } finally { + mock.restoreAll(); + } +}); + +test('keeps the deadline active while reading the response body', async () => { + mock.method(globalThis, 'fetch', async (input, { signal }) => new Response(new ReadableStream({ + start(controller) { + signal.addEventListener('abort', () => controller.error(signal.reason), { once: true }); + }, + }))); + try { + const response = await fetchWithTimeout('https://example.test', {}, 5); + await Promise.all([assert.rejects(response.text(), { name: 'TimeoutError' }), sleep(20)]); + } finally { + mock.restoreAll(); + } +}); + +test('preserves caller cancellation', async () => { + let signal; + mock.method(globalThis, 'fetch', async (input, init) => { signal = init.signal; return new Response('ok'); }); + try { + const controller = new AbortController; + await fetchWithTimeout('https://example.test', { signal: controller.signal }); + controller.abort(); + assert.equal(signal.aborted, true); + } finally { + mock.restoreAll(); + } +}); diff --git a/test/screenshots/screenshots.test.js b/test/screenshots/screenshots.test.js index 702a0ab..4884d70 100644 --- a/test/screenshots/screenshots.test.js +++ b/test/screenshots/screenshots.test.js @@ -30,8 +30,10 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie assert.equal(headers.get('authorization'), 'Bearer token'); 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: 'networkidle0' }); + assert.deepEqual(body.gotoOptions, { waitUntil: 'networkidle0', timeout: 30_000 }); assert.deepEqual(body.screenshotOptions, { type: 'png' }); + assert.equal(body.actionTimeout, 30_000); + assert.deepEqual(body.setExtraHTTPHeaders, { 'Cache-Control': 'no-cache' }); assert.deepEqual(result, { image: PNG, type: 'image/png', width: 2432, height: 1368 }); });