diff --git a/src/render/index.spec.ts b/src/render/index.spec.ts index 205e58ef..dc6d2318 100644 --- a/src/render/index.spec.ts +++ b/src/render/index.spec.ts @@ -127,6 +127,16 @@ describe('getRenderedContent', () => { expect(browser.contexts().length).toBe(0) }) + it('returns 502 when navigation fails (connection refused)', async () => { + const result = await getRenderedContent(browser, { + url: 'http://127.0.0.1:19999/', + }) + expect(result.status).toEqual(502) + expect(result.body.byteLength).toBe(0) + expect(result.evaluateResults).toHaveLength(0) + expect(browser.contexts.length).toBe(0) + }) + test('image/png', async () => { const result = await getRenderedContent(browser, { url: 'http://localhost:8003/test.png', diff --git a/src/render/index.ts b/src/render/index.ts index 37574355..2eeaf143 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -1,4 +1,5 @@ import type { Browser, Page, Response as PlaywrightResponse } from 'playwright' +import { errors as PlaywrightErrors } from 'playwright' import { runWithDefer } from 'with-defer' import { excludeCacheValidationHeaders } from '../lib/headers' @@ -38,6 +39,15 @@ function emptyRenderResult(): RenderResult { } } +function errorRenderResult(status: 502 | 504): RenderResult { + return { + status, + headers: {}, + body: Buffer.from(''), + evaluateResults: [], + } +} + function isRenderableContentType(contentType: string): boolean { // TODO: support more content types if (contentType.startsWith('text/html')) { @@ -71,13 +81,40 @@ async function collectEvaluateResults( ) } -async function navigatePage( +type NavigationResult = + | { + ok: true + response: PlaywrightResponse | null + evaluateResults: EvaluateResult[] + } + | { ok: false; status: 502 | 504 } + +function classifyNavigationError(error: unknown): 502 | 504 { + return error instanceof PlaywrightErrors.TimeoutError ? 504 : 502 +} + +function captureNavigationResponse(page: Page): { + get: () => PlaywrightResponse | null + detach: () => void +} { + let captured: PlaywrightResponse | null = null + const handler = (response: PlaywrightResponse) => { + if (response.request().isNavigationRequest()) { + captured = response + } + } + page.on('response', handler) + return { + get: () => captured, + detach: () => page.off('response', handler), + } +} + +async function tryGoto( page: Page, request: Required> & RenderRequest, -): Promise<{ - response: PlaywrightResponse | null - evaluateResults: EvaluateResult[] -} | null> { + capture: ReturnType, +): Promise { try { const response = await page.goto(request.url, { waitUntil: request.waitUntil, @@ -87,13 +124,9 @@ async function navigatePage( page, request.evaluates, ) - return { - response, - evaluateResults, - } + return { ok: true, response: response ?? capture.get(), evaluateResults } } catch (error) { - console.error(`navigate failed: ${request.url}`, error) - return null + return { ok: false, status: classifyNavigationError(error) } } } @@ -107,10 +140,33 @@ function computeResponseHeaders(response: PlaywrightResponse): { return headers } +async function navigatePage( + page: Page, + request: Required> & RenderRequest, +): Promise { + // Track the main-frame response so we can detect server replies even when + // page.goto() throws (e.g. 204 No Content aborts the document load). + const capture = captureNavigationResponse(page) + const result = await tryGoto(page, request, capture) + capture.detach() + const serverResponse = capture.get() + if (!result.ok && serverResponse) { + return { ok: true, response: serverResponse, evaluateResults: [] } + } + return result +} + +function hasNoBody(status: number): boolean { + return status === 204 || status === 304 || (status >= 100 && status < 200) +} + async function readRenderedBody( page: Page, response: PlaywrightResponse, ): Promise { + if (hasNoBody(response.status())) { + return Buffer.from('') + } const headers = { ...response.headers() } if (isRenderableContentType(headers['content-type'] || '')) { return Buffer.from(await page.content()) @@ -118,6 +174,26 @@ async function readRenderedBody( return response.body() } +async function buildRenderResult( + page: Page, + navigationResult: NavigationResult, +): Promise { + if (!navigationResult.ok) { + return errorRenderResult(navigationResult.status) + } + if (!navigationResult.response) { + // page.goto() returned null without an exception (e.g. origin sent 204 No Content). + // This is a valid upstream response, not a gateway error. + return emptyRenderResult() + } + return { + status: navigationResult.response.status(), + headers: computeResponseHeaders(navigationResult.response), + body: await readRenderedBody(page, navigationResult.response), + evaluateResults: navigationResult.evaluateResults, + } +} + export async function getRenderedContent( browser: Browser, request: RenderRequest, @@ -133,15 +209,6 @@ export async function getRenderedContent( const page = await context.newPage() const navigationResult = await navigatePage(page, normalizedRequest) - if (!navigationResult?.response) { - return emptyRenderResult() - } - - return { - status: navigationResult.response.status(), - headers: computeResponseHeaders(navigationResult.response), - body: await readRenderedBody(page, navigationResult.response), - evaluateResults: navigationResult.evaluateResults, - } + return buildRenderResult(page, navigationResult) }) }