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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"prepare": "simple-git-hooks",
"type:check": "pnpm -r run type:check && tsc",
"test": "pnpm run -r test && vitest run",
"test:coverage": "vitest run --coverage",
"test:coverage": "pnpm run -r test && vitest run --coverage",
"bench": "vitest bench --run",
"lint": "eslint --max-warnings=0 .",
"lint:fix": "eslint --max-warnings=0 --fix .",
Expand Down
50 changes: 3 additions & 47 deletions packages/aws-lambda/src/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,9 @@ describe('toStandardBody', () => {
await expect(toStandardBody(event({ body: undefined }))).resolves.toBeUndefined()
})

it('returns undefined when body is missing, even with content headers', async () => {
await expect(toStandardBody(event({
body: null,
multiValueHeaders: { 'Content-Type': ['application/json'] },
}))).resolves.toBeUndefined()
})

it('returns undefined when body is empty without content-type', async () => {
await expect(toStandardBody(event({ body: '' }))).resolves.toBeUndefined()
it('returns undefined for a body no content header describes', async () => {
// the hint comes from the headers alone, so unlabelled bytes are indistinguishable from no body
await expect(toStandardBody(event({ body: 'raw-data' }))).resolves.toBeUndefined()
})

it('parses an empty body when content-type is present', async () => {
Expand Down Expand Up @@ -64,13 +58,6 @@ describe('toStandardBody', () => {
}))).resolves.toEqual({ foo: 'bar' })
})

it('parses json with content-type parameters', async () => {
await expect(toStandardBody(event({
body: '{"foo":"bar"}',
multiValueHeaders: { 'Content-Type': ['application/json; charset=utf-8'] },
}))).resolves.toEqual({ foo: 'bar' })
})

it('parses base64-encoded json', async () => {
await expect(toStandardBody(event({
body: Buffer.from('{"foo":"bar"}').toString('base64'),
Expand Down Expand Up @@ -167,27 +154,6 @@ describe('toStandardBody', () => {
await expect(standardBody.text()).resolves.toBe('hello')
})

it('treats a body with content-length but uncommon content-type as file', async () => {
const standardBody = await toStandardBody(event({
body: 'raw-data',
multiValueHeaders: { 'Content-Length': ['8'] },
})) as File

expect(standardBody).toBeInstanceOf(File)
expect(standardBody.name).toBe('blob')
expect(standardBody.type).toBe('')
await expect(standardBody.text()).resolves.toBe('raw-data')
})

it('treats a body without content-length as octet-stream', async () => {
const standardBody = await toStandardBody(event({
body: 'raw-data',
})) as ReadableStream<Uint8Array>

expect(standardBody).toBeInstanceOf(ReadableStream)
await expect(new Response(standardBody).text()).resolves.toBe('raw-data')
})

it('respects the file hint over the content-type', async () => {
const standardBody = await toStandardBody(event({
body: '{"foo":"bar"}',
Expand All @@ -210,15 +176,5 @@ describe('toStandardBody', () => {
},
}), { hint: 'json' })).resolves.toEqual({ foo: 'bar' })
})

it('the standard-server header wins over the content-type', async () => {
await expect(toStandardBody(event({
body: '{"foo":"bar"}',
multiValueHeaders: {
'Content-Type': ['text/plain'],
'standard-server': ['json'],
},
}))).resolves.toEqual({ foo: 'bar' })
})
})
})
30 changes: 15 additions & 15 deletions packages/aws-lambda/src/body.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { StandardBody, StandardBodyHint } from '@standardserver/core'
import type { AnyAPIGatewayProxyEvent } from './types'
import { Buffer } from 'node:buffer'
import { flattenStandardHeader, getFilenameFromContentDisposition } from '@standardserver/core'
import { flattenStandardHeader, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standardserver/core'
import { toAsyncIteratorObject } from '@standardserver/fetch'
import { parseEmptyableJSON } from '@standardserver/shared'
import { getEventHeader } from './headers'
Expand All @@ -20,11 +20,14 @@ export async function toStandardBody(
event: AnyAPIGatewayProxyEvent,
options: ToStandardBodyOptions = {},
): Promise<StandardBody> {
const hint = options?.hint ?? flattenStandardHeader(getEventHeader(event, 'standard-server'))
const contentType = flattenStandardHeader(getEventHeader(event, 'content-type'))
const mimeType = contentType?.split(';')[0]?.trim()
const hint = options?.hint ?? resolveStandardBodyHint({
'standard-server': getEventHeader(event, 'standard-server'),
'content-type': getEventHeader(event, 'content-type'),
'content-length': getEventHeader(event, 'content-length'),
'content-disposition': getEventHeader(event, 'content-disposition'),
})

if (hint === 'none' || (hint === undefined && typeof event.body !== 'string')) {
if (hint === 'none') {
return undefined
}

Expand All @@ -34,28 +37,25 @@ export async function toStandardBody(
? Buffer.from(event.body, 'base64') as Uint8Array<ArrayBuffer>
: new TextEncoder().encode(event.body)

// the body is fully buffered so its emptiness is always known
if (hint === undefined && mimeType === undefined && bytes.length === 0) {
return undefined
}

if (hint === 'json' || (hint === undefined && mimeType === 'application/json')) {
if (hint === 'json') {
return parseEmptyableJSON(new TextDecoder().decode(bytes))
}

if (hint === 'form-data' || (hint === undefined && mimeType === 'multipart/form-data')) {
const contentType = flattenStandardHeader(getEventHeader(event, 'content-type'))

if (hint === 'form-data') {
return _bytesToFormData(bytes, contentType)
}

if (hint === 'url-search-params' || (hint === undefined && mimeType === 'application/x-www-form-urlencoded')) {
if (hint === 'url-search-params') {
return new URLSearchParams(new TextDecoder().decode(bytes))
}

if (hint === 'event-stream' || (hint === undefined && mimeType === 'text/event-stream')) {
if (hint === 'event-stream') {
return toAsyncIteratorObject(_bytesToReadableStream(bytes))
}

if (hint === 'file' || (hint === undefined && flattenStandardHeader(getEventHeader(event, 'content-length')) !== undefined)) {
if (hint === 'file') {
const contentDisposition = flattenStandardHeader(getEventHeader(event, 'content-disposition'))
const fileName = contentDisposition !== undefined
? getFilenameFromContentDisposition(contentDisposition)
Expand Down
1 change: 1 addition & 0 deletions packages/bun/tests/data-transfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ for (const [adapter, createClientServer] of ADAPTERS) {
},
},
{
// Bun drops empty headers like content-type, so only the body hint identifies this one
name: 'empty-file',
createBody: () => new File([], '', { type: '' }),
assertBody: async (body: any) => {
Expand Down
96 changes: 95 additions & 1 deletion packages/core/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { flattenStandardHeader, generateContentDisposition, getFilenameFromContentDisposition, mergeStandardHeaders, parseStandardUrl } from './utils'
import { flattenStandardHeader, generateContentDisposition, getFilenameFromContentDisposition, mergeStandardHeaders, parseStandardUrl, resolveStandardBodyHint } from './utils'

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -75,6 +75,100 @@ it('getFilenameFromContentDisposition', () => {
expect(getFilenameFromContentDisposition('attachment; filename*=iso-8859-1\'\'%E9.txt')).toEqual(undefined)
})

describe('resolveStandardBodyHint', () => {
it('standard-server header wins over content headers', () => {
expect(resolveStandardBodyHint({ 'standard-server': 'none', 'content-length': '3', 'content-type': 'application/pdf' })).toBe('none')
expect(resolveStandardBodyHint({ 'standard-server': 'json', 'content-length': '3', 'content-type': 'application/pdf' })).toBe('json')
expect(resolveStandardBodyHint({ 'standard-server': ['file'], 'content-type': 'application/json' })).toBe('file')
expect(resolveStandardBodyHint({ 'standard-server': 'octet-stream', 'content-length': '3' })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'standard-server': 'event-stream', 'content-length': '3', 'content-type': 'application/json' })).toBe('event-stream')
expect(resolveStandardBodyHint({ 'standard-server': 'form-data', 'content-length': '3', 'content-type': 'application/json' })).toBe('form-data')
expect(resolveStandardBodyHint({ 'standard-server': 'url-search-params', 'content-length': '3', 'content-type': 'application/json' })).toBe('url-search-params')
})

it('ignores an unrecognized standard-server header', () => {
expect(resolveStandardBodyHint({ 'standard-server': 'invalid', 'content-type': 'application/json' })).toBe('json')
expect(resolveStandardBodyHint({ 'standard-server': '', 'content-length': '3' })).toBe('file')
// a repeated header flattens to a comma-joined value, which is not a hint
expect(resolveStandardBodyHint({ 'standard-server': ['file', 'json'] })).toBe('none')
})

it('falls back to content headers when the standard-server header is unset', () => {
expect(resolveStandardBodyHint({ 'standard-server': undefined, 'content-length': '3' })).toBe('file')
// an empty array is the unset-header convention
expect(resolveStandardBodyHint({ 'standard-server': [], 'content-length': '3' })).toBe('file')
})

it('none when no content-type and no meaningful content-length', () => {
expect(resolveStandardBodyHint({})).toBe('none')
expect(resolveStandardBodyHint({ 'content-length': '0' })).toBe('none')
expect(resolveStandardBodyHint({ 'content-type': [], 'content-length': [] })).toBe('none')
})

it('by content-type', () => {
expect(resolveStandardBodyHint({ 'content-type': 'application/json' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': 'application/json; charset=utf-8' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': ' application/json ' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': 'multipart/form-data; boundary=x' })).toBe('form-data')
expect(resolveStandardBodyHint({ 'content-type': 'application/x-www-form-urlencoded' })).toBe('url-search-params')
expect(resolveStandardBodyHint({ 'content-type': 'text/event-stream' })).toBe('event-stream')

// content-type wins over content-length
expect(resolveStandardBodyHint({ 'content-type': 'application/json', 'content-length': '3' })).toBe('json')
})

it('matches the content-type case-insensitively, as media types are', () => {
expect(resolveStandardBodyHint({ 'content-type': 'Application/JSON' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': 'APPLICATION/JSON; CHARSET=UTF-8' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': 'Multipart/Form-Data; boundary=x' })).toBe('form-data')
expect(resolveStandardBodyHint({ 'content-type': 'APPLICATION/X-WWW-FORM-URLENCODED' })).toBe('url-search-params')
expect(resolveStandardBodyHint({ 'content-type': 'Text/Event-Stream' })).toBe('event-stream')

// the standard-server header is ours, so it stays exact and an uppercase value is not a hint
expect(resolveStandardBodyHint({ 'standard-server': 'JSON', 'content-type': 'application/pdf', 'content-length': '3' })).toBe('file')
expect(resolveStandardBodyHint({ 'standard-server': 'None' })).toBe('none')
})

it('file when content-length is present', () => {
expect(resolveStandardBodyHint({ 'content-length': '3' })).toBe('file')
expect(resolveStandardBodyHint({ 'content-length': ['3'] })).toBe('file')
expect(resolveStandardBodyHint({ 'content-length': '3', 'content-type': 'application/pdf' })).toBe('file')
// an empty file is still a file when a content-type is present
expect(resolveStandardBodyHint({ 'content-length': '0', 'content-type': 'application/pdf' })).toBe('file')
// an empty content-type is a valid content-type, not an absent one
expect(resolveStandardBodyHint({ 'content-length': '0', 'content-type': '' })).toBe('file')
expect(resolveStandardBodyHint({ 'content-length': '0', 'content-type': ' ; charset=utf-8' })).toBe('file')
})

it('file when content-disposition carries a filename', () => {
// a compressing proxy rewrites content-length, content-disposition reaches the receiver untouched
expect(resolveStandardBodyHint({ 'content-type': 'application/pdf', 'content-disposition': 'inline; filename="a.pdf"' })).toBe('file')
expect(resolveStandardBodyHint({ 'content-type': 'application/pdf', 'content-disposition': 'attachment; filename*=utf-8\'\'a.pdf' })).toBe('file')
// an empty filename is still a filename
expect(resolveStandardBodyHint({ 'content-type': 'application/pdf', 'content-disposition': 'attachment; filename=""' })).toBe('file')

// nothing to extract, so it says nothing about the body
expect(resolveStandardBodyHint({ 'content-type': 'application/pdf', 'content-disposition': 'attachment' })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'content-type': 'application/pdf', 'content-disposition': [] })).toBe('octet-stream')

// a filename does not rescue a body the other content headers already report as empty
expect(resolveStandardBodyHint({ 'content-disposition': 'inline; filename="a.pdf"' })).toBe('none')
expect(resolveStandardBodyHint({ 'content-disposition': 'inline; filename="a.pdf"', 'content-length': '0' })).toBe('none')

// a common content-type still wins
expect(resolveStandardBodyHint({ 'content-type': 'application/json', 'content-disposition': 'inline; filename="a.json"' })).toBe('json')
// and an explicit hint still wins over everything
expect(resolveStandardBodyHint({ 'standard-server': 'none', 'content-type': 'application/pdf', 'content-disposition': 'inline; filename="a.pdf"' })).toBe('none')
})

it('octet-stream when content-length is absent', () => {
expect(resolveStandardBodyHint({ 'content-type': 'application/octet-stream' })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'content-type': 'application/pdf' })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'content-type': 'text/plain', 'content-length': [] })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'content-type': '' })).toBe('octet-stream')
})
})

describe('mergeStandardHeaders', () => {
afterEach(() => {
expect(({} as any).polluted).toEqual(undefined)
Expand Down
61 changes: 60 additions & 1 deletion packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { StandardHeaders, StandardUrl } from './types'
import type { StandardBodyHint, StandardHeaders, StandardUrl } from './types'
import { toArray, tryDecodeURIComponent } from '@standardserver/shared'

export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string {
Expand Down Expand Up @@ -52,6 +52,65 @@ export function flattenStandardHeader(header: string | readonly string[] | undef
return header.join(', ')
}

const STANDARD_BODY_HINT_SET: ReadonlySet<string> = new Set<StandardBodyHint>([
'json',
'form-data',
'url-search-params',
'event-stream',
'octet-stream',
'file',
'none',
])

/**
* Resolves how a receiver parses a body from the standard headers alone,
* mirroring what the body parsers do.
*/
export function resolveStandardBodyHint(headers: {
'standard-server'?: undefined | string | string[]
'content-type'?: undefined | string | string[]
'content-length'?: undefined | string | string[]
'content-disposition'?: undefined | string | string[]
}): StandardBodyHint {
const hint = flattenStandardHeader(headers['standard-server'])

if (hint !== undefined && STANDARD_BODY_HINT_SET.has(hint)) {
return hint as StandardBodyHint
}

// media types are case-insensitive, the hint is our own header so it stays exact
const mimeType = flattenStandardHeader(headers['content-type'])?.split(';')[0]?.trim().toLowerCase()
const contentLength = flattenStandardHeader(headers['content-length'])
const contentDisposition = flattenStandardHeader(headers['content-disposition'])
const fileName = contentDisposition !== undefined ? getFilenameFromContentDisposition(contentDisposition) : undefined

if (mimeType === undefined && (contentLength === undefined || contentLength === '0')) {
return 'none'
}

if (mimeType === 'application/json') {
return 'json'
}

if (mimeType === 'multipart/form-data') {
return 'form-data'
}

if (mimeType === 'application/x-www-form-urlencoded') {
return 'url-search-params'
}

if (mimeType === 'text/event-stream') {
return 'event-stream'
}

if (fileName !== undefined || contentLength !== undefined) {
return 'file'
}

return 'octet-stream'
}

export function mergeStandardHeaders(a: StandardHeaders, b: StandardHeaders): StandardHeaders {
const merged = { ...a, ...b }

Expand Down
Loading
Loading