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 packages/aws-lambda/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ The package exposes these helpers:
| Group | Exports | Purpose |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Request and response | `toStandardLazyRequest()`, `sendStandardResponse()` | Adapt Lambda events and response streams to Standard Server |
| Lower-level helpers | `toStandardUrl()`, `toStandardHeaders()`, `getEventHeader()`, `toStandardBody()`, `toLambdaHeaders()` | Convert individual pieces of an event |
| Lower-level helpers | `toStandardUrl()`, `toStandardHeaders()`, `toStandardBody()`, `toLambdaHeaders()` | Convert individual pieces of an event |
| Types and option shapes | `APIGatewayProxyEvent`, `APIGatewayProxyEventV2`, `AnyAPIGatewayProxyEvent`, `HttpResponseStream`, `AwsLambdaGlobal`, `SendStandardResponseOptions` | Type handler inputs and serializer options |

`APIGatewayProxyEvent` and `APIGatewayProxyEventV2` are structural subsets of the same-named types from `@types/aws-lambda`, so events typed with either work; the adapter accepts both via `AnyAPIGatewayProxyEvent` and tells them apart by the top-level `httpMethod` field only payload format 1.0 carries. `AwsLambdaGlobal` describes the `awslambda` global the Lambda Node.js runtime injects — the package deliberately does not `declare global`, so importing it never pollutes your project's global types. Declare the global yourself where you need typed access to `awslambda.streamifyResponse`.
Expand Down
22 changes: 21 additions & 1 deletion packages/aws-lambda/src/body.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AsyncIteratorClass } from '@standard-server/shared'
import type { APIGatewayProxyEvent } from './types'
import type { AnyAPIGatewayProxyEvent, APIGatewayProxyEvent } from './types'
import { Buffer } from 'node:buffer'
import { toStandardBody } from './body'

Expand Down Expand Up @@ -173,6 +173,26 @@ describe('toStandardBody', () => {
await expect(standardBody.text()).resolves.toBe('hello')
})

it.each<[string, AnyAPIGatewayProxyEvent]>([
['v1 headers fallback', event({
body: 'hello',
multiValueHeaders: { 'X-Other': ['ignored'] },
headers: { 'Content-Type': 'text/plain', 'Content-Disposition': 'inline; filename="hello.txt"' },
})],
['v2', {
rawPath: '/',
requestContext: { http: { method: 'POST' } },
body: 'hello',
isBase64Encoded: false,
headers: { 'Content-Type': 'text/plain', 'Content-Disposition': 'inline; filename="hello.txt"' },
}],
])('reads content headers from single-value headers (%s)', async (_, e) => {
const standardBody = await toStandardBody(e) as File

expect(standardBody.name).toBe('hello.txt')
expect(standardBody.type).toBe('text/plain')
})

it('respects the file hint over the content-type', async () => {
const standardBody = await toStandardBody(event({
body: '{"foo":"bar"}',
Expand Down
14 changes: 5 additions & 9 deletions packages/aws-lambda/src/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Buffer } from 'node:buffer'
import { flattenStandardHeader, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standard-server/core'
import { toAsyncIteratorObject } from '@standard-server/fetch'
import { parseEmptyableJSON } from '@standard-server/shared'
import { getEventHeader } from './headers'
import { toStandardHeaders } from './headers'

export interface ToStandardBodyOptions {
/**
Expand All @@ -20,12 +20,8 @@ export async function toStandardBody(
event: AnyAPIGatewayProxyEvent,
options: ToStandardBodyOptions = {},
): Promise<StandardBody> {
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'),
})
const headers = toStandardHeaders(event)
const hint = options.hint ?? resolveStandardBodyHint(headers)

if (hint === 'none') {
return undefined
Expand All @@ -41,7 +37,7 @@ export async function toStandardBody(
return parseEmptyableJSON(new TextDecoder().decode(bytes))
}

const contentType = flattenStandardHeader(getEventHeader(event, 'content-type'))
const contentType = flattenStandardHeader(headers['content-type'])

if (hint === 'form-data') {
return _bytesToFormData(bytes, contentType)
Expand All @@ -56,7 +52,7 @@ export async function toStandardBody(
}

if (hint === 'file') {
const contentDisposition = flattenStandardHeader(getEventHeader(event, 'content-disposition'))
const contentDisposition = flattenStandardHeader(headers['content-disposition'])
const fileName = contentDisposition !== undefined
? getFilenameFromContentDisposition(contentDisposition)
: undefined
Expand Down
93 changes: 12 additions & 81 deletions packages/aws-lambda/src/headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,4 @@
import { getEventHeader, toLambdaHeaders, toStandardHeaders } from './headers'

describe('getEventHeader', () => {
it('reads case-insensitively from multiValueHeaders (v1)', () => {
const event = {
httpMethod: 'GET',
path: '/',
headers: { 'content-type': 'ignored' },
multiValueHeaders: {
'Content-Type': ['application/json'],
'X-Multi': ['one', 'two'],
'X-Empty': [],
'X-Skipped': undefined,
},
}

expect(getEventHeader(event, 'Content-Type')).toEqual(['application/json'])
expect(getEventHeader(event, 'x-multi')).toEqual(['one', 'two'])
expect(getEventHeader(event, 'x-empty')).toBeUndefined()
expect(getEventHeader(event, 'x-skipped')).toBeUndefined()
expect(getEventHeader(event, 'x-missing')).toBeUndefined()
})

it('falls through to headers for keys multiValueHeaders does not carry (v1)', () => {
expect(getEventHeader({
httpMethod: 'GET',
path: '/',
headers: { 'X-Only-In-Headers': 'kept' },
multiValueHeaders: { 'Content-Type': ['application/json'] },
}, 'x-only-in-headers')).toBe('kept')
})

it('reads case-insensitively from headers (v1 fallback and v2)', () => {
const event = {
rawPath: '/',
requestContext: { http: { method: 'GET' } },
headers: {
'Content-Type': 'application/json',
'X-Skipped': undefined,
},
}

expect(getEventHeader(event, 'Content-Type')).toBe('application/json')
expect(getEventHeader(event, 'x-skipped')).toBeUndefined()
expect(getEventHeader(event, 'x-missing')).toBeUndefined()

expect(getEventHeader({
httpMethod: 'GET',
path: '/',
multiValueHeaders: null,
headers: { 'X-Custom': 'value' },
}, 'x-custom')).toBe('value')
})

it('returns undefined when no headers are present', () => {
expect(getEventHeader({ httpMethod: 'GET', path: '/' }, 'content-type')).toBeUndefined()
expect(getEventHeader({ rawPath: '/', requestContext: { http: { method: 'GET' } } }, 'content-type')).toBeUndefined()
})

it('restores the cookie header from cookies (v2)', () => {
expect(getEventHeader({
rawPath: '/',
requestContext: { http: { method: 'GET' } },
cookies: ['foo=bar', 'bar=baz'],
}, 'Cookie')).toBe('foo=bar; bar=baz')

// a cookie header present in headers wins over the cookies field
expect(getEventHeader({
rawPath: '/',
requestContext: { http: { method: 'GET' } },
headers: { Cookie: 'a=b' },
cookies: ['foo=bar'],
}, 'cookie')).toBe('a=b')

expect(getEventHeader({
rawPath: '/',
requestContext: { http: { method: 'GET' } },
cookies: [],
}, 'cookie')).toBeUndefined()
})
})
import { toLambdaHeaders, toStandardHeaders } from './headers'

describe('toStandardHeaders (v2)', () => {
it('lowercases keys and restores the cookie header', () => {
Expand All @@ -98,6 +18,17 @@ describe('toStandardHeaders (v2)', () => {
})
})

it('prefers a cookie header over the cookies field', () => {
expect(toStandardHeaders({
rawPath: '/',
requestContext: { http: { method: 'GET' } },
headers: { Cookie: 'a=b' },
cookies: ['foo=bar'],
})).toEqual({
cookie: 'a=b',
})
})

it('ignores empty or missing cookies', () => {
expect(toStandardHeaders({
rawPath: '/',
Expand Down
31 changes: 0 additions & 31 deletions packages/aws-lambda/src/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,37 +60,6 @@ export function toStandardHeaders(event: AnyAPIGatewayProxyEvent): StandardHeade
return standardHeaders
}

/**
* Read a single header from an API Gateway proxy event, case-insensitively.
*/
export function getEventHeader(event: AnyAPIGatewayProxyEvent, key: string): string | string[] | undefined {
key = key.toLowerCase()

if ('httpMethod' in event && event.multiValueHeaders) {
for (const k of Object.keys(event.multiValueHeaders)) {
const headerValues = event.multiValueHeaders[k]
if (headerValues !== undefined && headerValues.length !== 0 && k.toLowerCase() === key) {
return headerValues
}
}
}

if (event.headers) {
for (const k of Object.keys(event.headers)) {
const headerValue = event.headers[k]
if (headerValue !== undefined && k.toLowerCase() === key) {
return headerValue
}
}
}

if (key === 'cookie' && !('httpMethod' in event) && event.cookies?.length) {
return event.cookies.join('; ')
}

return undefined
}

/**
* Split standard headers into the `headers` and `cookies` metadata fields.
* `set-cookie` values are kept separate because joining them would corrupt them.
Expand Down
Loading