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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ x-rendering-proxy-version: 1
| `evaluates` | `string[]` | `[]` | JavaScript snippets to evaluate before capturing the DOM. |
| `timeout` | `number` (ms) | none | Navigation timeout in milliseconds. |

Requests with a non-object `X-Rendering-Proxy` JSON payload or with fields of the wrong type are rejected with `400 Bad Request`.

**Response header** `X-Rendering-Proxy` — JSON array of `EvaluateResult`:

```ts
Expand Down
18 changes: 18 additions & 0 deletions src/server/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ describe('parseIncomingRenderRequest', () => {
})
})

it('returns badRequest when x-rendering-proxy is valid JSON but not an object', () => {
const req = makeRequest('/http://example.com', {
'x-rendering-proxy': '1234',
})
expect(parseIncomingRenderRequest(req)).toStrictEqual({
type: 'badRequest',
})
})

it('returns badRequest when x-rendering-proxy fields have invalid types', () => {
const req = makeRequest('/http://example.com', {
'x-rendering-proxy': '{"evaluates": 1234, "waitUntil": 3456}',
})
expect(parseIncomingRenderRequest(req)).toStrictEqual({
type: 'badRequest',
})
})

it('returns render type when x-rendering-proxy header is valid JSON', () => {
const req = makeRequest('/http://example.com', {
'x-rendering-proxy': '{"waitUntil":"load"}',
Expand Down
4 changes: 3 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ async function respondToIncomingRequest(
},
badRequest: async () => {
res.writeHead(400)
res.end('Bad Request: x-rendering-proxy header contains invalid JSON')
res.end(
'Bad Request: x-rendering-proxy header must be a valid JSON object',
)
},
render: async () => {
if (parsedRequest.type !== 'render') {
Expand Down
37 changes: 16 additions & 21 deletions src/server/request_options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { parseRenderingProxyHeader } from './request_options'

describe('parseRenderingProxyHeader', () => {
it('returns default when header is absent, empty, or contains valid JSON with wrong types', async () => {
it('returns default when header is absent or empty', async () => {
expect(parseRenderingProxyHeader(undefined)).toStrictEqual({
waitUntil: 'load',
evaluates: [],
Expand All @@ -19,23 +19,9 @@ describe('parseRenderingProxyHeader', () => {
evaluates: [],
timeout: undefined,
})
// Valid JSON but not an object — unknown fields normalize to defaults
expect(parseRenderingProxyHeader('1234')).toStrictEqual({
waitUntil: 'load',
evaluates: [],
timeout: undefined,
})
// Valid JSON with wrong field types — normalize to defaults
expect(
parseRenderingProxyHeader('{"evaluates": 1234, "waitUntil": 3456}'),
).toStrictEqual({
waitUntil: 'load',
evaluates: [],
timeout: undefined,
})
})

it('throws SyntaxError for syntactically invalid JSON in a non-empty header', async () => {
it('throws for syntactically invalid JSON in a non-empty header', async () => {
// Callers (e.g. the HTTP server) must catch and return 400 Bad Request
expect(() => parseRenderingProxyHeader('{')).toThrow(SyntaxError)
expect(() => parseRenderingProxyHeader('[unclosed')).toThrow(SyntaxError)
Expand All @@ -44,6 +30,20 @@ describe('parseRenderingProxyHeader', () => {
)
})

it('throws when the header is valid JSON but not a JSON object', async () => {
expect(() => parseRenderingProxyHeader('1234')).toThrow(TypeError)
expect(() => parseRenderingProxyHeader('["script"]')).toThrow(TypeError)
})

it('throws when field types do not match the protocol schema', async () => {
expect(() =>
parseRenderingProxyHeader('{"evaluates": 1234, "waitUntil": 3456}'),
).toThrow(TypeError)
expect(() => parseRenderingProxyHeader('{"timeout": "fast"}')).toThrow(
TypeError,
)
})

it('parses evaluates', async () => {
expect(parseRenderingProxyHeader('{"evaluates": []}')).toStrictEqual({
waitUntil: 'load',
Expand Down Expand Up @@ -109,11 +109,6 @@ describe('parseRenderingProxyHeader', () => {
evaluates: [],
timeout: undefined,
})
expect(parseRenderingProxyHeader('{"timeout": "fast"}')).toStrictEqual({
waitUntil: 'load',
evaluates: [],
timeout: undefined,
})
})

it('truncates evaluates to at most 10 entries', async () => {
Expand Down
47 changes: 46 additions & 1 deletion src/server/request_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ interface RequestOption {
timeout: number | undefined
}

type RequestOptionInput = {
waitUntil?: unknown
evaluates?: unknown
timeout?: unknown
}

export function parseRenderingProxyHeader(
headerValue: undefined | string | string[],
): RequestOption {
Expand All @@ -26,6 +32,10 @@ function tryParseOptions(text: string): unknown {
return JSON.parse(text)
}

function isRequestOptionInput(value: unknown): value is RequestOptionInput {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function normalizeWaitUntil(value: unknown): LifecycleEvent {
if (lifeCycleEvents.includes(value as LifecycleEvent)) {
return value as LifecycleEvent
Expand All @@ -52,8 +62,43 @@ function normalizeTimeout(value: unknown): number | undefined {
return typeof value === 'number' && value > 0 ? value : undefined
}

function validateWaitUntil(value: RequestOptionInput): void {
if ('waitUntil' in value && typeof value.waitUntil !== 'string') {
throw new TypeError('X-Rendering-Proxy.waitUntil must be a string')
}
}

function validateEvaluates(value: RequestOptionInput): void {
if (
'evaluates' in value &&
(!Array.isArray(value.evaluates) ||
!value.evaluates.every((item) => typeof item === 'string'))
) {
throw new TypeError('X-Rendering-Proxy.evaluates must be a string array')
}
}

function validateTimeout(value: RequestOptionInput): void {
if ('timeout' in value && typeof value.timeout !== 'number') {
throw new TypeError('X-Rendering-Proxy.timeout must be a number')
}
}

function validateOptionsShape(value: unknown): RequestOptionInput | null {
if (value === null) {
return null
}
if (!isRequestOptionInput(value)) {
throw new TypeError('X-Rendering-Proxy must be a JSON object')
}
validateWaitUntil(value)
validateEvaluates(value)
validateTimeout(value)
return value
}

function parseOptions(text: string): RequestOption {
const baseParsed = tryParseOptions(text) as Partial<RequestOption> | null
const baseParsed = validateOptionsShape(tryParseOptions(text))

return {
waitUntil: normalizeWaitUntil(baseParsed?.waitUntil),
Expand Down
Loading