Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/render/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
109 changes: 88 additions & 21 deletions src/render/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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<Pick<RenderRequest, 'url' | 'waitUntil'>> & RenderRequest,
): Promise<{
response: PlaywrightResponse | null
evaluateResults: EvaluateResult[]
} | null> {
capture: ReturnType<typeof captureNavigationResponse>,
): Promise<NavigationResult> {
try {
const response = await page.goto(request.url, {
waitUntil: request.waitUntil,
Expand All @@ -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) }
}
}

Expand All @@ -107,17 +140,60 @@ function computeResponseHeaders(response: PlaywrightResponse): {
return headers
}

async function navigatePage(
page: Page,
request: Required<Pick<RenderRequest, 'url' | 'waitUntil'>> & RenderRequest,
): Promise<NavigationResult> {
// 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<Buffer> {
if (hasNoBody(response.status())) {
return Buffer.from('')
}
const headers = { ...response.headers() }
if (isRenderableContentType(headers['content-type'] || '')) {
return Buffer.from(await page.content())
}
return response.body()
}

async function buildRenderResult(
page: Page,
navigationResult: NavigationResult,
): Promise<RenderResult> {
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,
Expand All @@ -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)
})
}
Loading