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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ Standard Server abstracts away the complexities of handling different communicat

## Packages

| Package | Purpose | Main entry points |
| ------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `@standardserver/core` | Shared types, validators, and utilities | `StandardRequest`, `StandardLazyRequest`, `StandardResponse`, `StandardLazyResponse` |
| `@standardserver/fastify` | Fastify adapter | `toStandardLazyRequest`, `sendStandardResponse` |
| `@standardserver/fetch` | Fetch API adapter | `toStandardLazyRequest`, `toFetchResponse`, `toStandardLazyResponse`, `toFetchBody`, `toFetchHeaders` |
| `@standardserver/node` | Node.js HTTP/HTTP2 adapter | `toStandardLazyRequest`, `sendStandardResponse` |
| `@standardserver/peer` | Message-based adapter | `ClientPeer`, `ServerPeer`, `encodePeerMessage`, `decodePeerMessage` |
| Package | Purpose | Main entry points |
| ---------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `@standardserver/aws-lambda` | AWS Lambda adapter (response streaming) | `toStandardLazyRequest`, `sendStandardResponse` |
| `@standardserver/core` | Shared types, validators, and utilities | `StandardRequest`, `StandardLazyRequest`, `StandardResponse`, `StandardLazyResponse` |
| `@standardserver/fastify` | Fastify adapter | `toStandardLazyRequest`, `sendStandardResponse` |
| `@standardserver/fetch` | Fetch API adapter | `toStandardLazyRequest`, `toFetchResponse`, `toStandardLazyResponse`, `toFetchBody`, `toFetchHeaders` |
| `@standardserver/node` | Node.js HTTP/HTTP2 adapter | `toStandardLazyRequest`, `sendStandardResponse` |
| `@standardserver/peer` | Message-based adapter | `ClientPeer`, `ServerPeer`, `encodePeerMessage`, `decodePeerMessage` |

## Standard Request and Response

Expand Down
237 changes: 237 additions & 0 deletions packages/aws-lambda/README.md

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions packages/aws-lambda/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@standardserver/aws-lambda",
"type": "module",
"version": "0.6.0",
"license": "MIT",
"homepage": "https://standardserver.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/middleapi/standardserver.git",
"directory": "packages/aws-lambda"
},
"sideEffects": false,
"publishConfig": {
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
}
}
},
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "unbuild",
"type:check": "tsc -b"
},
"dependencies": {
"@standardserver/core": "workspace:*",
"@standardserver/fetch": "workspace:*",
"@standardserver/node": "workspace:*",
"@standardserver/shared": "workspace:*"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.162",
"@types/node": "^26.1.2"
}
}
224 changes: 224 additions & 0 deletions packages/aws-lambda/src/body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import type { AsyncIteratorClass } from '@standardserver/shared'
import type { APIGatewayProxyEvent } from './types'
import { Buffer } from 'node:buffer'
import { toStandardBody } from './body'

function event(override: Partial<APIGatewayProxyEvent>): APIGatewayProxyEvent {
return {
httpMethod: 'POST',
path: '/',
body: null,
isBase64Encoded: false,
...override,
}
}

describe('toStandardBody', () => {
describe('empty', () => {
it('returns undefined when body is missing', async () => {
await expect(toStandardBody(event({ body: null }))).resolves.toBeUndefined()
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('parses an empty body when content-type is present', async () => {
await expect(toStandardBody(event({
body: '',
multiValueHeaders: { 'Content-Type': ['application/x-www-form-urlencoded'] },
}))).resolves.toEqual(new URLSearchParams())
})

it('respects the none hint', async () => {
await expect(toStandardBody(event({
body: '{"foo":"bar"}',
multiValueHeaders: { 'Content-Type': ['application/json'] },
}), { hint: 'none' })).resolves.toBeUndefined()

await expect(toStandardBody(event({
body: '{"foo":"bar"}',
multiValueHeaders: { 'standard-server': ['none'] },
}))).resolves.toBeUndefined()
})

it('parses a missing body as empty when a hint is provided', async () => {
await expect(toStandardBody(event({ body: null }), { hint: 'json' })).resolves.toBeUndefined()
await expect(toStandardBody(event({ body: null }), { hint: 'url-search-params' })).resolves.toEqual(new URLSearchParams())
})
})

describe('json', () => {
it('parses json', async () => {
await expect(toStandardBody(event({
body: '{"foo":"bar"}',
multiValueHeaders: { 'Content-Type': ['application/json'] },
}))).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'),
isBase64Encoded: true,
multiValueHeaders: { 'Content-Type': ['application/json'] },
}))).resolves.toEqual({ foo: 'bar' })
})

it('parses empty json as undefined', async () => {
await expect(toStandardBody(event({
body: '',
multiValueHeaders: { 'Content-Type': ['application/json'] },
}))).resolves.toBeUndefined()
})
})

describe('url-search-params', () => {
it('parses url-encoded forms', async () => {
await expect(toStandardBody(event({
body: 'foo=bar&baz=qux',
multiValueHeaders: { 'Content-Type': ['application/x-www-form-urlencoded'] },
}))).resolves.toEqual(new URLSearchParams('foo=bar&baz=qux'))
})
})

describe('form-data', () => {
it('parses multipart forms', async () => {
const form = new FormData()
form.append('foo', 'bar')
form.append('file', new File(['content'], 'file.txt', { type: 'text/plain' }))

const encoded = new Response(form)

const standardBody = await toStandardBody(event({
body: Buffer.from(await encoded.arrayBuffer()).toString('base64'),
isBase64Encoded: true,
multiValueHeaders: { 'Content-Type': [encoded.headers.get('content-type')!] },
})) as FormData

expect(standardBody).toBeInstanceOf(FormData)
expect(standardBody.get('foo')).toBe('bar')
expect((standardBody.get('file') as File).name).toBe('file.txt')
await expect((standardBody.get('file') as File).text()).resolves.toBe('content')
})

it('rejects on form-data hint without content-type', async () => {
await expect(toStandardBody(event({
body: 'not-multipart',
}), { hint: 'form-data' })).rejects.toThrow()
})
})

describe('event-stream', () => {
it('parses server-sent events', async () => {
const standardBody = await toStandardBody(event({
body: ': \n\nevent: message\ndata: "foo"\n\nevent: close\ndata: "baz"\n\n',
multiValueHeaders: { 'Content-Type': ['text/event-stream'] },
})) as AsyncIteratorClass<unknown>

await expect(standardBody.next()).resolves.toEqual({ done: false, value: 'foo' })
await expect(standardBody.next()).resolves.toEqual({ done: true, value: 'baz' })
})
})

describe('octet-stream', () => {
it('streams the body on explicit hint', async () => {
const standardBody = await toStandardBody(event({
body: 'raw-data',
multiValueHeaders: {
'Content-Type': ['application/octet-stream'],
'standard-server': ['octet-stream'],
},
})) as ReadableStream<Uint8Array>

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

describe('file', () => {
it('parses file with filename from content-disposition', async () => {
const standardBody = await toStandardBody(event({
body: 'hello',
multiValueHeaders: {
'Content-Type': ['text/plain'],
'Content-Disposition': ['inline; filename="hello.txt"'],
'Content-Length': ['5'],
},
})) as File

expect(standardBody).toBeInstanceOf(File)
expect(standardBody.name).toBe('hello.txt')
expect(standardBody.type).toBe('text/plain')
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"}',
multiValueHeaders: { 'Content-Type': ['application/json'] },
}), { hint: 'file' }) as File

expect(standardBody).toBeInstanceOf(File)
expect(standardBody.name).toBe('blob')
await expect(standardBody.text()).resolves.toBe('{"foo":"bar"}')
})
})

describe('hint', () => {
it('the hint option wins over the standard-server header', async () => {
await expect(toStandardBody(event({
body: '{"foo":"bar"}',
multiValueHeaders: {
'Content-Type': ['text/plain'],
'standard-server': ['file'],
},
}), { 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' })
})
})
})
87 changes: 87 additions & 0 deletions packages/aws-lambda/src/body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { StandardBody, StandardBodyHint } from '@standardserver/core'
import type { AnyAPIGatewayProxyEvent } from './types'
import { Buffer } from 'node:buffer'
import { flattenStandardHeader, getFilenameFromContentDisposition } from '@standardserver/core'
import { toAsyncIteratorObject } from '@standardserver/fetch'
import { parseEmptyableJSON } from '@standardserver/shared'
import { getEventHeader } from './headers'

export interface ToStandardBodyOptions {
/**
* Hints on how the body should be parsed.
*/
hint?: StandardBodyHint | undefined
}

/**
* Parses the fully buffered, optionally base64-encoded body of an API Gateway proxy event.
*/
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()

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

const bytes: Uint8Array<ArrayBuffer> = typeof event.body !== 'string'
? new Uint8Array()
: event.isBase64Encoded
? 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')) {
return parseEmptyableJSON(new TextDecoder().decode(bytes))
}

if (hint === 'form-data' || (hint === undefined && mimeType === 'multipart/form-data')) {
return _bytesToFormData(bytes, contentType)
}

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

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

if (hint === 'file' || (hint === undefined && flattenStandardHeader(getEventHeader(event, 'content-length')) !== undefined)) {
const contentDisposition = flattenStandardHeader(getEventHeader(event, 'content-disposition'))
const fileName = contentDisposition !== undefined
? getFilenameFromContentDisposition(contentDisposition)
: undefined

return new File([bytes], fileName ?? 'blob', { type: contentType ?? '' })
}

return _bytesToReadableStream(bytes)
}

function _bytesToFormData(bytes: Uint8Array<ArrayBuffer>, contentType: string | undefined): Promise<FormData> {
const response = new Response(bytes, {
headers: {
'content-type': contentType ?? '',
},
})

return response.formData()
}

function _bytesToReadableStream(bytes: Uint8Array<ArrayBuffer>): ReadableStream<Uint8Array<ArrayBuffer>> {
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
}
Loading
Loading