diff --git a/README.md b/README.md
index ebbcf96..95f7911 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md
new file mode 100644
index 0000000..06d5ffd
--- /dev/null
+++ b/packages/aws-lambda/README.md
@@ -0,0 +1,237 @@
+# @standardserver/aws-lambda
+
+
+
+`@standardserver/aws-lambda` adapts AWS Lambda events and response streams to the transport-agnostic request and response model defined by Standard Server.
+
+Standard Server provides a unified interface for client-server communication across HTTP and message-based transports. It lets you write handlers against the same request, response, body, and streaming primitives whether the underlying transport is the Fetch API, Node.js HTTP, HTTP/2, or a peer-style message channel.
+
+This package is the AWS Lambda adapter for that model. It converts an API Gateway proxy event — payload format version 1.0 or 2.0, the latter also used by Lambda Function URLs — into a `StandardLazyRequest`, and writes a `StandardResponse` back through the stream provided by `awslambda.streamifyResponse`, so streaming bodies such as server-sent events flow to the client as they are produced instead of being buffered.
+
+## Entry Point
+
+The package exports a single entry point:
+
+| Export | Purpose |
+| ---------------------------- | ---------------------------------------------------------- |
+| `@standardserver/aws-lambda` | AWS Lambda adapter helpers for events and response streams |
+
+## Package overview
+
+The main entry point 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 |
+| 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`.
+
+## Server-side request handling
+
+Use `toStandardLazyRequest()` to convert the incoming event into a `StandardLazyRequest`, then `sendStandardResponse()` to write the resulting `StandardResponse` back through the response stream. The handler must be wrapped with `awslambda.streamifyResponse`, and the function must run on the AWS Lambda Node.js runtime with response streaming enabled.
+
+```ts
+import type { AwsLambdaGlobal } from '@standardserver/aws-lambda'
+import type { StandardLazyRequest, StandardResponse } from '@standardserver/core'
+import { sendStandardResponse, toStandardLazyRequest } from '@standardserver/aws-lambda'
+
+// injected by the AWS Lambda Node.js runtime when response streaming is enabled
+declare const awslambda: AwsLambdaGlobal
+
+async function handle(request: StandardLazyRequest): Promise {
+ const body = await request.resolveBody()
+
+ return {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ body: {
+ ok: true,
+ method: request.method,
+ url: request.url,
+ received: body,
+ },
+ }
+}
+
+export const handler = awslambda.streamifyResponse(async (event, responseStream, context) => {
+ const standardRequest = toStandardLazyRequest(event, responseStream)
+ const standardResponse = await handle(standardRequest)
+
+ await sendStandardResponse(responseStream, standardResponse, {/** options */})
+})
+```
+
+`sendStandardResponse()` sends the status, headers, and cookies as the response stream metadata prelude via `awslambda.HttpResponseStream.from()`, then streams the body. It resolves once the response is fully flushed, and rejects if the stream errors.
+
+> [!TIP]
+> When sending responses, you can pass additional options such as event-stream keep-alive.
+
+## Resolving Body
+
+The event carries the request body as a fully buffered, optionally base64-encoded string. `resolveBody(hint?)` decodes it and determines how to parse it using the following priority:
+
+1. If `hint?` is provided, use it as the `StandardBodyHint`.
+2. Otherwise, if the `standard-server` header is present, use it as the `StandardBodyHint`.
+3. Otherwise, if `content-type` is one of the common types, parse accordingly.
+4. Otherwise, if `content-length` exists, treat the body as `file`; if not, treat it as `octet-stream`.
+
+> [!TIP]
+> For efficient communication, set the `standard-server` header to explicitly hint the body type, especially for file or binary streaming. For example, if you upload a file with a common `content-type` such as `application/json` but omit the `standard-server` header, the server may interpret it as JSON and parse it unexpectedly.
+
+## Lambda behavior to be aware of
+
+- **Response streaming must be enabled.** `sendStandardResponse()` relies on the `awslambda` global, which only exists on the AWS Lambda Node.js runtime, and on the metadata prelude of `awslambda.HttpResponseStream`, which the platform only interprets for streaming-enabled invocations.
+- **`set-cookie` is sent via metadata cookies.** Multiple cookies survive because they are sent through the dedicated `cookies` metadata field; every other multi-value header is joined with `, `.
+- **Request bodies are buffered.** API Gateway delivers the whole request body at once, so request-side streaming degrades to a single buffered chunk. Response-side streaming is real streaming.
+- **Payload format 1.0 query strings are re-encoded.** API Gateway delivers them url-decoded, so the adapter re-encodes them when reconstructing the standard url. Payload format 2.0 provides the already encoded `rawQueryString`, which is used as-is.
+- **Payload format 2.0 cookies are restored.** API Gateway strips the `cookie` header into the separate `cookies` field, and the adapter joins them back into a `cookie` header on the standard request.
+
+## Learn more
+
+For the higher-level project overview, see the root [Standard Server README](../../README.md).
+
+For the Node.js primitives this adapter is built on, see the [Node.js adapter documentation](../node/README.md).
+
+## Sponsors
+
+Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀
+
+### 🏆 Platinum Sponsor
+
+
+
+### 🥈 Silver Sponsor
+
+
+
+### Generous Sponsors
+
+
+
+### Sponsors
+
+
+
+### Backers
+
+
+
+### Past Sponsors
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json
new file mode 100644
index 0000000..cf015d6
--- /dev/null
+++ b/packages/aws-lambda/package.json
@@ -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"
+ }
+}
diff --git a/packages/aws-lambda/src/body.test.ts b/packages/aws-lambda/src/body.test.ts
new file mode 100644
index 0000000..7a9ad20
--- /dev/null
+++ b/packages/aws-lambda/src/body.test.ts
@@ -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 {
+ 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
+
+ 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
+
+ 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
+
+ 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' })
+ })
+ })
+})
diff --git a/packages/aws-lambda/src/body.ts b/packages/aws-lambda/src/body.ts
new file mode 100644
index 0000000..d73382a
--- /dev/null
+++ b/packages/aws-lambda/src/body.ts
@@ -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 {
+ 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 = typeof event.body !== 'string'
+ ? new Uint8Array()
+ : event.isBase64Encoded
+ ? Buffer.from(event.body, 'base64') as Uint8Array
+ : 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, contentType: string | undefined): Promise {
+ const response = new Response(bytes, {
+ headers: {
+ 'content-type': contentType ?? '',
+ },
+ })
+
+ return response.formData()
+}
+
+function _bytesToReadableStream(bytes: Uint8Array): ReadableStream> {
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(bytes)
+ controller.close()
+ },
+ })
+}
diff --git a/packages/aws-lambda/src/headers.test.ts b/packages/aws-lambda/src/headers.test.ts
new file mode 100644
index 0000000..5f6706b
--- /dev/null
+++ b/packages/aws-lambda/src/headers.test.ts
@@ -0,0 +1,210 @@
+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()
+ })
+})
+
+describe('toStandardHeaders (v2)', () => {
+ it('lowercases keys and restores the cookie header', () => {
+ expect(toStandardHeaders({
+ rawPath: '/',
+ requestContext: { http: { method: 'GET' } },
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Custom': 'one, two',
+ 'X-Skipped': undefined,
+ },
+ cookies: ['foo=bar', 'bar=baz'],
+ })).toEqual({
+ 'content-type': 'application/json',
+ 'x-custom': 'one, two',
+ 'cookie': 'foo=bar; bar=baz',
+ })
+ })
+
+ it('ignores empty or missing cookies', () => {
+ expect(toStandardHeaders({
+ rawPath: '/',
+ requestContext: { http: { method: 'GET' } },
+ headers: { 'x-custom': 'value' },
+ cookies: [],
+ })).toEqual({
+ 'x-custom': 'value',
+ })
+
+ expect(toStandardHeaders({
+ rawPath: '/',
+ requestContext: { http: { method: 'GET' } },
+ })).toEqual({})
+ })
+})
+
+describe('toStandardHeaders (v1)', () => {
+ it('merges both sources, preferring multiValueHeaders per key, and lowercases keys', () => {
+ expect(toStandardHeaders({
+ httpMethod: 'GET',
+ path: '/',
+ headers: {
+ 'Content-Type': 'ignored in favor of multiValueHeaders',
+ 'X-Only-In-Headers': 'kept',
+ 'X-Skipped-Single': undefined,
+ },
+ multiValueHeaders: {
+ 'Content-Type': ['application/json'],
+ 'X-Custom': ['one', 'two'],
+ 'X-Empty': [],
+ 'X-Skipped': undefined,
+ },
+ })).toEqual({
+ 'content-type': 'application/json',
+ 'x-custom': ['one', 'two'],
+ 'x-only-in-headers': 'kept',
+ })
+ })
+
+ it('merges multiValueHeaders keys differing only in case', () => {
+ expect(toStandardHeaders({
+ httpMethod: 'GET',
+ path: '/',
+ multiValueHeaders: {
+ 'x-custom': ['one'],
+ 'X-Custom': ['two', 'three'],
+ },
+ })).toEqual({
+ 'x-custom': ['one', 'two', 'three'],
+ })
+ })
+
+ it('falls back to headers', () => {
+ expect(toStandardHeaders({
+ httpMethod: 'GET',
+ path: '/',
+ multiValueHeaders: null,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-custom': 'value',
+ 'x-skipped': undefined,
+ },
+ })).toEqual({
+ 'content-type': 'application/json',
+ 'x-custom': 'value',
+ })
+ })
+
+ it('returns an empty object when no headers are present', () => {
+ expect(toStandardHeaders({ httpMethod: 'GET', path: '/' })).toEqual({})
+ })
+
+ it('keeps __proto__ header as a plain own property', () => {
+ const headers = toStandardHeaders({
+ httpMethod: 'GET',
+ path: '/',
+ multiValueHeaders: JSON.parse('{"__proto__": ["injected"]}'),
+ })
+
+ expect(Object.getOwnPropertyDescriptor(headers, '__proto__')?.value).toBe('injected')
+ expect(Object.getPrototypeOf(headers)).toBe(null)
+ })
+})
+
+describe('toLambdaHeaders', () => {
+ it('joins multi-value headers and separates set-cookie', () => {
+ expect(toLambdaHeaders({
+ 'content-type': 'application/json',
+ 'x-custom': ['one', 'two'],
+ 'x-skipped': undefined,
+ 'set-cookie': ['foo=bar', 'bar=baz'],
+ })).toEqual([
+ {
+ 'content-type': 'application/json',
+ 'x-custom': 'one, two',
+ },
+ ['foo=bar', 'bar=baz'],
+ ])
+ })
+
+ it('supports a single set-cookie string case-insensitively', () => {
+ expect(toLambdaHeaders({
+ 'Set-Cookie': 'foo=bar',
+ })).toEqual([
+ {},
+ ['foo=bar'],
+ ])
+ })
+})
diff --git a/packages/aws-lambda/src/headers.ts b/packages/aws-lambda/src/headers.ts
new file mode 100644
index 0000000..d4f3ec0
--- /dev/null
+++ b/packages/aws-lambda/src/headers.ts
@@ -0,0 +1,121 @@
+import type { StandardHeaders } from '@standardserver/core'
+import type { AnyAPIGatewayProxyEvent } from './types'
+import { toArray } from '@standardserver/shared'
+
+/**
+ * Convert API Gateway proxy event headers to standard headers.
+ *
+ * Payload format 1.0 merges `multiValueHeaders` and `headers`, preferring
+ * `multiValueHeaders` per key. Payload format 2.0 restores the `cookie`
+ * header from `cookies`.
+ */
+export function toStandardHeaders(event: AnyAPIGatewayProxyEvent): StandardHeaders {
+ const standardHeaders: StandardHeaders = Object.create(null)
+
+ const append = (key: string, values: string[]) => {
+ const lowerKey = key.toLowerCase()
+ const existing = standardHeaders[lowerKey]
+
+ const merged = existing === undefined ? values : [...toArray(existing), ...values]
+ standardHeaders[lowerKey] = merged.length === 1 ? merged[0] : merged
+ }
+
+ if (!('httpMethod' in event)) {
+ if (event.headers) {
+ for (const key in event.headers) {
+ const value = event.headers[key]
+ if (value !== undefined) {
+ append(key, [value])
+ }
+ }
+ }
+
+ if (event.cookies?.length && standardHeaders.cookie === undefined) {
+ append('cookie', [event.cookies.join('; ')])
+ }
+
+ return standardHeaders
+ }
+
+ if (event.multiValueHeaders) {
+ for (const key in event.multiValueHeaders) {
+ const values = event.multiValueHeaders[key]
+ if (values !== undefined && values.length !== 0) {
+ append(key, values)
+ }
+ }
+ }
+
+ if (event.headers) {
+ for (const key in event.headers) {
+ const value = event.headers[key]
+ // `multiValueHeaders` is a superset of `headers` in real events,
+ // only fill in keys it does not already carry
+ if (value !== undefined && standardHeaders[key.toLowerCase()] === undefined) {
+ append(key, [value])
+ }
+ }
+ }
+
+ 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 in event.multiValueHeaders) {
+ const headerValues = event.multiValueHeaders[k]
+ if (headerValues !== undefined && headerValues.length !== 0 && k.toLowerCase() === key) {
+ return headerValues
+ }
+ }
+ }
+
+ if (event.headers) {
+ for (const k in 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.
+ */
+export function toLambdaHeaders(standardHeaders: StandardHeaders): [
+ headers: Record,
+ setCookies: string[],
+] {
+ const headers: Record = Object.create(null)
+ const setCookies: string[] = []
+
+ for (const key in standardHeaders) {
+ const value = standardHeaders[key]
+
+ if (value === undefined) {
+ continue
+ }
+
+ if (key.toLowerCase() === 'set-cookie') {
+ setCookies.push(...toArray(value))
+ }
+ else {
+ headers[key] = Array.isArray(value) ? value.join(', ') : value
+ }
+ }
+
+ return [headers, setCookies]
+}
diff --git a/packages/aws-lambda/src/index.test.ts b/packages/aws-lambda/src/index.test.ts
new file mode 100644
index 0000000..dc72b5c
--- /dev/null
+++ b/packages/aws-lambda/src/index.test.ts
@@ -0,0 +1,5 @@
+import * as index from '.'
+
+it('export something', () => {
+ expect(Object.keys(index).length).toBeGreaterThan(0)
+})
diff --git a/packages/aws-lambda/src/index.ts b/packages/aws-lambda/src/index.ts
new file mode 100644
index 0000000..edd37a8
--- /dev/null
+++ b/packages/aws-lambda/src/index.ts
@@ -0,0 +1,6 @@
+export * from './body'
+export * from './headers'
+export * from './request'
+export * from './response'
+export * from './types'
+export * from './url'
diff --git a/packages/aws-lambda/src/request.test.ts b/packages/aws-lambda/src/request.test.ts
new file mode 100644
index 0000000..8b8d760
--- /dev/null
+++ b/packages/aws-lambda/src/request.test.ts
@@ -0,0 +1,112 @@
+import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, HttpResponseStream } from './types'
+import Stream from 'node:stream'
+import * as StandardServerNode from '@standardserver/node'
+import * as Body from './body'
+import * as Headers from './headers'
+import { toStandardLazyRequest } from './request'
+import * as Url from './url'
+
+const toStandardBodySpy = vi.spyOn(Body, 'toStandardBody')
+const toStandardHeadersSpy = vi.spyOn(Headers, 'toStandardHeaders')
+const toStandardUrlSpy = vi.spyOn(Url, 'toStandardUrl')
+const toAbortSignalSpy = vi.spyOn(StandardServerNode, 'toAbortSignal')
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+function createResponseStream(): HttpResponseStream {
+ const stream = new Stream.Writable({
+ write(chunk, encoding, callback) {
+ callback()
+ },
+ }) as HttpResponseStream
+
+ stream.setContentType = vi.fn()
+
+ return stream
+}
+
+describe('toStandardLazyRequest', () => {
+ const event: APIGatewayProxyEvent = {
+ httpMethod: 'POST',
+ path: '/example',
+ multiValueHeaders: { 'Content-Type': ['application/json'] },
+ multiValueQueryStringParameters: { foo: ['bar'] },
+ body: '{"foo":"bar"}',
+ isBase64Encoded: false,
+ }
+
+ it('works', async () => {
+ const responseStream = createResponseStream()
+
+ const standardRequest = toStandardLazyRequest(event, responseStream)
+
+ expect(toAbortSignalSpy).toBeCalledTimes(1)
+ expect(toAbortSignalSpy).toBeCalledWith(responseStream)
+ expect(standardRequest.signal).toBe(toAbortSignalSpy.mock.results[0]!.value)
+
+ expect(toStandardUrlSpy).toBeCalledTimes(1)
+ expect(toStandardUrlSpy).toBeCalledWith(event)
+ expect(standardRequest.url).toBe('/example?foo=bar')
+
+ expect(standardRequest.method).toBe('POST')
+
+ expect(standardRequest.headers).toEqual({ 'content-type': 'application/json' })
+ expect(toStandardHeadersSpy).toBeCalledTimes(1)
+ expect(toStandardHeadersSpy).toBeCalledWith(event)
+
+ await expect(standardRequest.resolveBody('json')).resolves.toEqual({ foo: 'bar' })
+ expect(toStandardBodySpy).toBeCalledTimes(1)
+ expect(toStandardBodySpy).toBeCalledWith(event, { hint: 'json' })
+ })
+
+ it('works with a v2 event', async () => {
+ const eventV2: APIGatewayProxyEventV2 = {
+ rawPath: '/example',
+ rawQueryString: 'foo=bar',
+ requestContext: { http: { method: 'PUT' } },
+ headers: { 'Content-Type': 'application/json' },
+ body: '{"foo":"bar"}',
+ isBase64Encoded: false,
+ }
+
+ const standardRequest = toStandardLazyRequest(eventV2, createResponseStream())
+
+ expect(standardRequest.method).toBe('PUT')
+ expect(standardRequest.url).toBe('/example?foo=bar')
+ expect(standardRequest.headers).toEqual({ 'content-type': 'application/json' })
+ await expect(standardRequest.resolveBody()).resolves.toEqual({ foo: 'bar' })
+ })
+
+ it('headers is lazy and can override', () => {
+ const lazyRequest = toStandardLazyRequest(event, createResponseStream())
+
+ expect(toStandardHeadersSpy).toBeCalledTimes(0)
+ lazyRequest.headers = { overrided: '1' }
+ expect(lazyRequest.headers).toEqual({ overrided: '1' }) // can override before access
+ expect(toStandardHeadersSpy).toBeCalledTimes(0)
+
+ const lazyRequest2 = toStandardLazyRequest(event, createResponseStream())
+ expect(lazyRequest2.headers).toEqual(toStandardHeadersSpy.mock.results[0]!.value)
+ expect(lazyRequest2.headers).toEqual(toStandardHeadersSpy.mock.results[0]!.value) // ensure cached
+ expect(toStandardHeadersSpy).toBeCalledTimes(1)
+
+ lazyRequest2.headers = { overrided: '2' } // can override after access
+ expect(lazyRequest2.headers).toEqual({ overrided: '2' })
+ })
+
+ it('signal aborts when the response stream closes early', async () => {
+ const responseStream = createResponseStream()
+
+ const standardRequest = toStandardLazyRequest(event, responseStream)
+
+ expect(standardRequest.signal!.aborted).toBe(false)
+
+ responseStream.destroy()
+
+ await vi.waitFor(() => {
+ expect(standardRequest.signal!.aborted).toBe(true)
+ })
+ })
+})
diff --git a/packages/aws-lambda/src/request.ts b/packages/aws-lambda/src/request.ts
new file mode 100644
index 0000000..96a0579
--- /dev/null
+++ b/packages/aws-lambda/src/request.ts
@@ -0,0 +1,34 @@
+import type { StandardLazyRequest } from '@standardserver/core'
+import type { AnyAPIGatewayProxyEvent, HttpResponseStream } from './types'
+import { toAbortSignal } from '@standardserver/node'
+import { toStandardBody } from './body'
+import { toStandardHeaders } from './headers'
+import { toStandardUrl } from './url'
+
+/**
+ * Convert an API Gateway proxy event to a standard lazy request.
+ */
+export function toStandardLazyRequest(
+ event: AnyAPIGatewayProxyEvent,
+ responseStream: HttpResponseStream,
+): StandardLazyRequest {
+ // DON'T lazy load signal, because we need register event listener as soon as possible
+ // to make the signal abort in time
+ const signal = toAbortSignal(responseStream)
+
+ return {
+ url: toStandardUrl(event),
+ method: 'httpMethod' in event ? event.httpMethod : event.requestContext.http.method,
+ get headers() {
+ // lazy headers to improve performance
+ const headers = toStandardHeaders(event)
+ Object.defineProperty(this, 'headers', { value: headers, writable: true })
+ return headers
+ },
+ set headers(value) {
+ Object.defineProperty(this, 'headers', { value, writable: true })
+ },
+ resolveBody: hint => toStandardBody(event, { hint }),
+ signal,
+ }
+}
diff --git a/packages/aws-lambda/src/response.test.ts b/packages/aws-lambda/src/response.test.ts
new file mode 100644
index 0000000..df8b52b
--- /dev/null
+++ b/packages/aws-lambda/src/response.test.ts
@@ -0,0 +1,305 @@
+import type { StandardResponse } from '@standardserver/core'
+import type { HttpResponseStream } from './types'
+import { Buffer } from 'node:buffer'
+import Stream from 'node:stream'
+import * as StandardServerNode from '@standardserver/node'
+import { sendStandardResponse } from './response'
+
+const toNodeHttpBodySpy = vi.spyOn(StandardServerNode, 'toNodeHttpBody')
+
+const DELIMITER = new Uint8Array(8)
+
+const fromSpy = vi.fn((responseStream: HttpResponseStream, metadata: Record) => {
+ // mimics what the lambda runtime does: send the metadata prelude
+ // ahead of the body on the very same stream
+ responseStream.setContentType('application/vnd.awslambda.http-integration-response')
+ responseStream.write(JSON.stringify(metadata))
+ responseStream.write(DELIMITER)
+ return responseStream
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('awslambda', { HttpResponseStream: { from: fromSpy } })
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+function createResponseStream(): HttpResponseStream & { chunks: Buffer[] } {
+ const stream = new Stream.Writable({
+ write(chunk, encoding, callback) {
+ stream.chunks.push(chunk)
+ callback()
+ },
+ }) as HttpResponseStream & { chunks: Buffer[] }
+
+ stream.chunks = []
+ stream.setContentType = vi.fn()
+
+ return stream
+}
+
+function metadataOf(stream: HttpResponseStream & { chunks: Buffer[] }): Record {
+ return JSON.parse(stream.chunks[0]!.toString())
+}
+
+function bodyOf(stream: HttpResponseStream & { chunks: Buffer[] }): string {
+ return Buffer.concat(stream.chunks.slice(2)).toString()
+}
+
+describe('sendStandardResponse', () => {
+ it('buffered (empty)', async () => {
+ const responseStream = createResponseStream()
+
+ const options = { eventStream: { keepAlive: { enabled: true } } }
+ await sendStandardResponse(responseStream, {
+ status: 207,
+ headers: {
+ 'x-custom-header': 'custom-value',
+ },
+ body: undefined,
+ }, options)
+
+ expect(toNodeHttpBodySpy).toBeCalledTimes(1)
+ expect(toNodeHttpBodySpy).toBeCalledWith(undefined, {
+ 'x-custom-header': 'custom-value',
+ }, options)
+
+ expect(fromSpy).toBeCalledTimes(1)
+ expect(metadataOf(responseStream)).toEqual({
+ statusCode: 207,
+ headers: {
+ 'x-custom-header': 'custom-value',
+ },
+ cookies: [],
+ })
+
+ expect(bodyOf(responseStream)).toBe('')
+ expect(responseStream.writableEnded).toBe(true)
+ })
+
+ it('buffered (json)', async () => {
+ const responseStream = createResponseStream()
+
+ await sendStandardResponse(responseStream, {
+ status: 200,
+ headers: {
+ 'x-custom-header': 'custom-value',
+ 'set-cookie': ['foo=bar', 'bar=baz'],
+ },
+ body: { foo: 'bar' },
+ })
+
+ expect(metadataOf(responseStream)).toEqual({
+ statusCode: 200,
+ headers: {
+ 'content-type': 'application/json',
+ 'x-custom-header': 'custom-value',
+ },
+ cookies: ['foo=bar', 'bar=baz'],
+ })
+
+ expect(bodyOf(responseStream)).toBe('{"foo":"bar"}')
+ expect(responseStream.writableEnded).toBe(true)
+ })
+
+ it('chunked (async generator)', async () => {
+ const responseStream = createResponseStream()
+
+ async function* gen() {
+ yield 'foo'
+ yield 'bar'
+ return 'baz'
+ }
+
+ await sendStandardResponse(responseStream, {
+ status: 200,
+ headers: {},
+ body: gen(),
+ })
+
+ expect(metadataOf(responseStream)).toMatchObject({
+ statusCode: 200,
+ headers: {
+ 'content-type': 'text/event-stream',
+ },
+ })
+
+ expect(bodyOf(responseStream)).toBe(': \n\nevent: message\ndata: "foo"\n\nevent: message\ndata: "bar"\n\nevent: close\ndata: "baz"\n\n')
+ expect(responseStream.writableEnded).toBe(true)
+ })
+
+ it('chunked (octet-stream)', async () => {
+ const responseStream = createResponseStream()
+
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode('chunk1'))
+ controller.enqueue(new TextEncoder().encode('chunk2'))
+ controller.close()
+ },
+ })
+
+ await sendStandardResponse(responseStream, {
+ status: 200,
+ headers: {},
+ body: stream,
+ })
+
+ expect(metadataOf(responseStream)).toMatchObject({
+ statusCode: 200,
+ headers: {
+ 'content-type': 'application/octet-stream',
+ },
+ })
+
+ expect(bodyOf(responseStream)).toBe('chunk1chunk2')
+ expect(responseStream.writableEnded).toBe(true)
+ })
+
+ it('destroys the response stream when the body stream errors during streaming', async () => {
+ const responseStream = createResponseStream()
+
+ const error = new Error('TEST')
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode('chunk1'))
+ controller.error(error)
+ },
+ })
+
+ await expect(sendStandardResponse(responseStream, {
+ status: 200,
+ headers: {},
+ body: stream,
+ })).rejects.toThrow('TEST')
+
+ expect(responseStream.destroyed).toBe(true)
+ expect(responseStream.errored).toBe(error)
+ })
+
+ it('destroys the body when the response stream closes while streaming', async () => {
+ const responseStream = createResponseStream()
+
+ let clean = false
+ const standardResponse: StandardResponse = {
+ body: (async function* () {
+ try {
+ yield 1
+ await new Promise(r => setTimeout(r, 100))
+ yield 2
+ await new Promise(r => setTimeout(r, 9999999))
+ yield 3
+ }
+ finally {
+ clean = true
+ }
+ })(),
+ headers: {},
+ status: 200,
+ }
+
+ const sendPromise = expect(sendStandardResponse(responseStream, standardResponse)).rejects.toThrow('test')
+
+ await vi.waitFor(() => {
+ expect(bodyOf(responseStream)).toContain('data: 1')
+ })
+
+ responseStream.destroy(new Error('test'))
+
+ await vi.waitFor(() => {
+ expect(clean).toBe(true)
+ })
+
+ await sendPromise
+ })
+
+ it('destroys the body without error when the response stream closes cleanly while streaming', async () => {
+ const responseStream = createResponseStream()
+
+ let clean = false
+ const standardResponse: StandardResponse = {
+ body: (async function* () {
+ try {
+ yield 1
+ await new Promise(r => setTimeout(r, 100))
+ yield 2
+ await new Promise(r => setTimeout(r, 9999999))
+ yield 3
+ }
+ finally {
+ clean = true
+ }
+ })(),
+ headers: {},
+ status: 200,
+ }
+
+ const sendPromise = sendStandardResponse(responseStream, standardResponse)
+
+ await vi.waitFor(() => {
+ expect(bodyOf(responseStream)).toContain('data: 1')
+ })
+
+ responseStream.destroy()
+
+ await vi.waitFor(() => {
+ expect(clean).toBe(true)
+ })
+
+ await sendPromise
+ })
+
+ describe('response stream closed before sending', () => {
+ it('resolves and destroys the body', async () => {
+ const responseStream = createResponseStream()
+ responseStream.destroy()
+
+ await vi.waitFor(() => {
+ expect(responseStream.closed).toBe(true)
+ })
+
+ let clean = false
+ const standardResponse: StandardResponse = {
+ body: (async function* () {
+ try {
+ yield 1
+ }
+ finally {
+ clean = true
+ }
+ })(),
+ headers: {},
+ status: 200,
+ }
+
+ await expect(sendStandardResponse(responseStream, standardResponse)).resolves.toBeUndefined()
+
+ await vi.waitFor(() => {
+ expect(clean).toBe(true)
+ })
+
+ expect(fromSpy).not.toHaveBeenCalled()
+ })
+
+ it('rejects when the response stream was destroyed with an error', async () => {
+ const responseStream = createResponseStream()
+ responseStream.once('error', () => {})
+ responseStream.destroy(new Error('test'))
+
+ await vi.waitFor(() => {
+ expect(responseStream.closed).toBe(true)
+ })
+
+ await expect(sendStandardResponse(responseStream, {
+ status: 200,
+ headers: {},
+ body: { foo: 'bar' },
+ })).rejects.toThrow('test')
+
+ expect(fromSpy).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/aws-lambda/src/response.ts b/packages/aws-lambda/src/response.ts
new file mode 100644
index 0000000..767d131
--- /dev/null
+++ b/packages/aws-lambda/src/response.ts
@@ -0,0 +1,80 @@
+import type { StandardResponse } from '@standardserver/core'
+import type { ToNodeHttpBodyOptions } from '@standardserver/node'
+import type { AwsLambdaGlobal, HttpResponseStream } from './types'
+import { canWriteToNodeResponse, getNodeResponseError, toNodeHttpBody } from '@standardserver/node'
+import { toLambdaHeaders } from './headers'
+
+/**
+ * Injected by the Lambda runtime when response streaming is enabled.
+ * Declared module-locally to keep the consumer's global types clean.
+ */
+declare const awslambda: AwsLambdaGlobal
+
+export interface SendStandardResponseOptions extends ToNodeHttpBodyOptions {
+}
+
+/**
+ * Send a standard response through the stream `awslambda.streamifyResponse` provides.
+ *
+ * Requires the Lambda Node.js runtime with response streaming enabled.
+ */
+export async function sendStandardResponse(
+ responseStream: HttpResponseStream,
+ standardResponse: StandardResponse,
+ options: SendStandardResponseOptions = {},
+): Promise {
+ const [resBody, resHeaders] = toNodeHttpBody(standardResponse.body, standardResponse.headers, options)
+
+ return new Promise((resolve, reject) => {
+ if (!canWriteToNodeResponse(responseStream)) {
+ const error = getNodeResponseError(responseStream)
+
+ if (typeof resBody === 'object' && !resBody.closed) {
+ resBody.on('error', reject)
+ resBody.destroy(error ?? undefined)
+ }
+
+ if (error) {
+ reject(error)
+ }
+ else {
+ resolve()
+ }
+
+ return
+ }
+
+ const [headers, setCookies] = toLambdaHeaders(resHeaders)
+
+ // sends the metadata prelude (status, headers, cookies) and
+ // returns the stream the body should be written to
+ const res = awslambda.HttpResponseStream.from(responseStream, {
+ statusCode: standardResponse.status,
+ headers,
+ cookies: setCookies,
+ })
+
+ res.once('error', reject)
+ res.once('close', resolve)
+
+ if (resBody === undefined) {
+ // NOTE: Lambda functions don't allow passing undefined to `res.end`
+ res.end()
+ }
+ else if (typeof resBody === 'string') {
+ res.end(resBody)
+ }
+ else {
+ res.once('close', () => {
+ if (!resBody.closed) {
+ resBody.destroy(getNodeResponseError(res) ?? undefined)
+ }
+ })
+
+ // WARNING: errors that occur here are silently ignored and not reported to the Promise
+ resBody.once('error', error => res.destroy(error))
+
+ resBody.pipe(res)
+ }
+ })
+}
diff --git a/packages/aws-lambda/src/types.test-d.ts b/packages/aws-lambda/src/types.test-d.ts
new file mode 100644
index 0000000..6ec455f
--- /dev/null
+++ b/packages/aws-lambda/src/types.test-d.ts
@@ -0,0 +1,21 @@
+import type * as awsLambda from 'aws-lambda'
+import type { AnyAPIGatewayProxyEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2, HttpResponseStream } from './types'
+
+it('APIGatewayProxyEvent', () => {
+ expectTypeOf().toExtend()
+ expectTypeOf().not.toExtend()
+})
+
+it('APIGatewayProxyEventV2', () => {
+ expectTypeOf().toExtend()
+ expectTypeOf().not.toExtend()
+})
+
+it('AnyAPIGatewayProxyEvent', () => {
+ const _v1: AnyAPIGatewayProxyEvent = {} as awsLambda.APIGatewayProxyEvent
+ const _v2: AnyAPIGatewayProxyEvent = {} as awsLambda.APIGatewayProxyEventV2
+})
+
+it('HttpResponseStream', () => {
+ expectTypeOf[1]>().toExtend()
+})
diff --git a/packages/aws-lambda/src/types.ts b/packages/aws-lambda/src/types.ts
new file mode 100644
index 0000000..6d63378
--- /dev/null
+++ b/packages/aws-lambda/src/types.ts
@@ -0,0 +1,111 @@
+import type { Writable } from 'node:stream'
+
+/**
+ * AWS API Gateway proxy event, payload format 1.0.
+ *
+ * A structural subset of the same-named type from `@types/aws-lambda`.
+ */
+export interface APIGatewayProxyEvent {
+ /**
+ * @example 'GET', 'POST', etc.
+ */
+ httpMethod: string
+ /**
+ * @example '/example'
+ */
+ path: string
+ /**
+ * Keys can be any case.
+ */
+ headers?: Record | null
+ /**
+ * Preferred over `headers`, it can carry duplicated headers.
+ */
+ multiValueHeaders?: Record | null
+ /**
+ * Values are already url-decoded.
+ */
+ queryStringParameters?: Record | null
+ /**
+ * Preferred over `queryStringParameters`, it can carry duplicated parameters.
+ */
+ multiValueQueryStringParameters?: Record | null
+ /**
+ * Base64-encoded when `isBase64Encoded` is `true`.
+ */
+ body?: string | null
+ isBase64Encoded?: boolean
+}
+
+/**
+ * AWS API Gateway proxy event, payload format 2.0, also used by Lambda Function URLs.
+ *
+ * A structural subset of the same-named type from `@types/aws-lambda`.
+ */
+export interface APIGatewayProxyEventV2 {
+ /**
+ * @example '/example'
+ */
+ rawPath: string
+ /**
+ * Already url-encoded, without the leading `?`.
+ */
+ rawQueryString?: string
+ /**
+ * Keys can be any case, duplicated headers arrive joined with commas.
+ */
+ headers?: Record | null
+ /**
+ * Extracted from the `cookie` header.
+ */
+ cookies?: string[] | null
+ requestContext: {
+ http: {
+ /**
+ * @example 'GET', 'POST', etc.
+ */
+ method: string
+ }
+ }
+ /**
+ * Base64-encoded when `isBase64Encoded` is `true`.
+ */
+ body?: string | null
+ isBase64Encoded?: boolean
+}
+
+/**
+ * Any supported API Gateway proxy event.
+ * Payload format 1.0 is detected by its top-level `httpMethod` field.
+ */
+export type AnyAPIGatewayProxyEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2
+
+/**
+ * The stream `awslambda.streamifyResponse` provides to write the response to.
+ */
+export interface HttpResponseStream extends Writable {
+ setContentType: (contentType: string) => void
+}
+
+/**
+ * Shape of the `awslambda` global the Lambda Node.js runtime injects
+ * when response streaming is enabled.
+ *
+ * Not declared globally to keep the consumer's global types clean,
+ * declare it yourself: `declare const awslambda: AwsLambdaGlobal`
+ */
+export interface AwsLambdaGlobal {
+ streamifyResponse(
+ handler: (event: TEvent, responseStream: HttpResponseStream, context: TContext) => Promise | void,
+ ): (event: TEvent, responseStream: HttpResponseStream, context: TContext) => Promise | void
+
+ HttpResponseStream: {
+ /**
+ * Sends the metadata prelude and returns the stream to write the body to.
+ */
+ from(
+ responseStream: HttpResponseStream,
+ metadata: { statusCode: number, headers: Record, cookies: string[] } & Record,
+ ): HttpResponseStream
+ }
+}
diff --git a/packages/aws-lambda/src/url.test.ts b/packages/aws-lambda/src/url.test.ts
new file mode 100644
index 0000000..8707901
--- /dev/null
+++ b/packages/aws-lambda/src/url.test.ts
@@ -0,0 +1,94 @@
+import { toStandardUrl } from './url'
+
+describe('toStandardUrl (v2)', () => {
+ it('works without query string', () => {
+ expect(toStandardUrl({
+ rawPath: '/example',
+ requestContext: { http: { method: 'GET' } },
+ })).toBe('/example')
+
+ expect(toStandardUrl({
+ rawPath: '/example',
+ rawQueryString: '',
+ requestContext: { http: { method: 'GET' } },
+ })).toBe('/example')
+ })
+
+ it('adds a leading slash when missing', () => {
+ expect(toStandardUrl({
+ rawPath: 'example',
+ requestContext: { http: { method: 'GET' } },
+ })).toBe('/example')
+ })
+
+ it('uses rawQueryString as-is', () => {
+ expect(toStandardUrl({
+ rawPath: '/example',
+ rawQueryString: 'foo=bar&foo=baz&a+key=a+value%26%3D%23',
+ requestContext: { http: { method: 'GET' } },
+ })).toBe('/example?foo=bar&foo=baz&a+key=a+value%26%3D%23')
+ })
+})
+
+describe('toStandardUrl (v1)', () => {
+ it('works without query string', () => {
+ expect(toStandardUrl({ httpMethod: 'GET', path: '/example' })).toBe('/example')
+ })
+
+ it('detects v1 by the top-level httpMethod field', () => {
+ // a v1 event carries a requestContext too, it must not be mistaken for v2
+ expect(toStandardUrl({
+ httpMethod: 'GET',
+ path: '/example',
+ requestContext: {},
+ queryStringParameters: { foo: 'bar' },
+ } as any)).toBe('/example?foo=bar')
+ })
+
+ it('adds a leading slash when missing', () => {
+ expect(toStandardUrl({ httpMethod: 'GET', path: 'example' })).toBe('/example')
+ })
+
+ it('merges both sources, preferring multiValueQueryStringParameters per key', () => {
+ expect(toStandardUrl({
+ httpMethod: 'GET',
+ path: '/example',
+ queryStringParameters: { foo: 'ignored', only: 'kept' },
+ multiValueQueryStringParameters: { foo: ['bar', 'baz'], hello: ['world'] },
+ })).toBe('/example?foo=bar&foo=baz&hello=world&only=kept')
+ })
+
+ it('skips undefined multiValueQueryStringParameters values', () => {
+ expect(toStandardUrl({
+ httpMethod: 'GET',
+ path: '/example',
+ multiValueQueryStringParameters: { foo: ['bar'], skipped: undefined },
+ })).toBe('/example?foo=bar')
+ })
+
+ it('falls back to queryStringParameters', () => {
+ expect(toStandardUrl({
+ httpMethod: 'GET',
+ path: '/example',
+ queryStringParameters: { foo: 'bar', empty: '', skipped: undefined },
+ multiValueQueryStringParameters: null,
+ })).toBe('/example?foo=bar&empty=')
+ })
+
+ it('re-encodes decoded query string parameters', () => {
+ expect(toStandardUrl({
+ httpMethod: 'GET',
+ path: '/example',
+ multiValueQueryStringParameters: { 'a key': ['a value&=#'] },
+ })).toBe(`/example?${new URLSearchParams({ 'a key': 'a value&=#' })}`)
+ })
+
+ it('ignores empty query containers', () => {
+ expect(toStandardUrl({
+ httpMethod: 'GET',
+ path: '/example',
+ queryStringParameters: null,
+ multiValueQueryStringParameters: {},
+ })).toBe('/example')
+ })
+})
diff --git a/packages/aws-lambda/src/url.ts b/packages/aws-lambda/src/url.ts
new file mode 100644
index 0000000..053427c
--- /dev/null
+++ b/packages/aws-lambda/src/url.ts
@@ -0,0 +1,43 @@
+import type { StandardUrl } from '@standardserver/core'
+import type { AnyAPIGatewayProxyEvent } from './types'
+
+/**
+ * Build a standard url from an API Gateway proxy event.
+ *
+ * Payload format 1.0 query parameters are re-encoded (delivered url-decoded),
+ * payload format 2.0's `rawQueryString` is used as-is.
+ */
+export function toStandardUrl(event: AnyAPIGatewayProxyEvent): StandardUrl {
+ if (!('httpMethod' in event)) {
+ const pathname = `${event.rawPath.startsWith('/') ? '' : '/'}${event.rawPath}` as `/${string}`
+
+ return event.rawQueryString ? `${pathname}?${event.rawQueryString}` : pathname
+ }
+
+ const pathname = `${event.path.startsWith('/') ? '' : '/'}${event.path}` as `/${string}`
+
+ const query = new URLSearchParams()
+
+ if (event.multiValueQueryStringParameters) {
+ for (const key in event.multiValueQueryStringParameters) {
+ for (const value of event.multiValueQueryStringParameters[key] ?? []) {
+ query.append(key, value)
+ }
+ }
+ }
+
+ if (event.queryStringParameters) {
+ for (const key in event.queryStringParameters) {
+ const value = event.queryStringParameters[key]
+ // `multiValueQueryStringParameters` is a superset of `queryStringParameters`
+ // in real events, only fill in keys it does not already carry
+ if (value !== undefined && !query.has(key)) {
+ query.append(key, value)
+ }
+ }
+ }
+
+ const search = query.toString()
+
+ return search === '' ? pathname : `${pathname}?${search}`
+}
diff --git a/packages/aws-lambda/tsconfig.json b/packages/aws-lambda/tsconfig.json
new file mode 100644
index 0000000..5a18adc
--- /dev/null
+++ b/packages/aws-lambda/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../tsconfig.lib.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "references": [
+ { "path": "../node" }
+ ],
+ "include": ["./package.json", "src"],
+ "exclude": [
+ "**/*.test.*",
+ "**/*.test-d.ts",
+ "**/*.bench.*",
+ "**/__tests__/**",
+ "**/__mocks__/**",
+ "**/__snapshots__/**"
+ ]
+}
diff --git a/packages/fastify/package.json b/packages/fastify/package.json
index c084392..df186e9 100644
--- a/packages/fastify/package.json
+++ b/packages/fastify/package.json
@@ -40,7 +40,7 @@
},
"devDependencies": {
"@fastify/cookie": "^11.1.2",
- "@types/node": "^26.0.0",
+ "@types/node": "^26.1.2",
"@types/supertest": "^7.2.1",
"fastify": "^5.11.0",
"supertest": "^7.1.4"
diff --git a/packages/node/package.json b/packages/node/package.json
index 07a6f72..f8050b9 100644
--- a/packages/node/package.json
+++ b/packages/node/package.json
@@ -37,7 +37,7 @@
"@standardserver/shared": "workspace:*"
},
"devDependencies": {
- "@types/node": "^26.0.0",
+ "@types/node": "^26.1.2",
"@types/supertest": "^7.2.1",
"supertest": "^7.1.4"
}
diff --git a/packages/node/src/utils.ts b/packages/node/src/utils.ts
index 98b1563..a342312 100644
--- a/packages/node/src/utils.ts
+++ b/packages/node/src/utils.ts
@@ -1,12 +1,28 @@
import type Stream from 'node:stream'
import type { NodeHttpResponse } from './types'
+/**
+ * Check both the response itself and its underlying stream (http2) are still writable.
+ */
export function canWriteToNodeResponse(res: Stream.Writable | NodeHttpResponse): boolean {
- const stream = 'stream' in res ? res.stream : res
+ if ('stream' in res && !_canWriteToStream(res.stream)) {
+ return false
+ }
+
+ return _canWriteToStream(res)
+}
+
+function _canWriteToStream(stream: Stream.Writable): boolean {
return !stream.closed && !stream.destroyed && !stream.writableFinished && !stream.writableEnded
}
+/**
+ * Get the error of the response, preferring its underlying stream's (http2).
+ */
export function getNodeResponseError(res: Stream.Writable | NodeHttpResponse): Error | null {
- const stream = 'stream' in res ? res.stream : res
- return stream.errored
+ if ('stream' in res) {
+ return res.stream.errored ?? res.errored ?? null
+ }
+
+ return res.errored
}
diff --git a/playgrounds/hono-node/package.json b/playgrounds/hono-node/package.json
index 75894eb..7224118 100644
--- a/playgrounds/hono-node/package.json
+++ b/playgrounds/hono-node/package.json
@@ -8,10 +8,10 @@
"type:check": "tsc --noEmit"
},
"devDependencies": {
- "@hono/node-server": "^2.0.1",
+ "@hono/node-server": "^2.0.12",
"@standardserver/core": "latest",
"@standardserver/fetch": "latest",
- "@types/node": "^26.0.0",
+ "@types/node": "^26.1.2",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
diff --git a/playgrounds/node-http/package.json b/playgrounds/node-http/package.json
index 5ba1517..d716651 100644
--- a/playgrounds/node-http/package.json
+++ b/playgrounds/node-http/package.json
@@ -10,7 +10,7 @@
"devDependencies": {
"@standardserver/core": "latest",
"@standardserver/node": "latest",
- "@types/node": "^26.0.0",
+ "@types/node": "^26.1.2",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f53b980..2e1748c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -96,6 +96,28 @@ importers:
specifier: ^8.21.1
version: 8.21.1
+ packages/aws-lambda:
+ dependencies:
+ '@standardserver/core':
+ specifier: workspace:*
+ version: link:../core
+ '@standardserver/fetch':
+ specifier: workspace:*
+ version: link:../fetch
+ '@standardserver/node':
+ specifier: workspace:*
+ version: link:../node
+ '@standardserver/shared':
+ specifier: workspace:*
+ version: link:../shared
+ devDependencies:
+ '@types/aws-lambda':
+ specifier: ^8.10.162
+ version: 8.10.162
+ '@types/node':
+ specifier: ^26.1.2
+ version: 26.1.2
+
packages/bun:
devDependencies:
'@standardserver/core':
@@ -157,7 +179,7 @@ importers:
specifier: ^11.1.2
version: 11.1.2
'@types/node':
- specifier: ^26.0.0
+ specifier: ^26.1.2
version: 26.1.2
'@types/supertest':
specifier: ^7.2.1
@@ -191,7 +213,7 @@ importers:
version: link:../shared
devDependencies:
'@types/node':
- specifier: ^26.0.0
+ specifier: ^26.1.2
version: 26.1.2
'@types/supertest':
specifier: ^7.2.1
@@ -214,7 +236,7 @@ importers:
playgrounds/hono-node:
devDependencies:
'@hono/node-server':
- specifier: ^2.0.1
+ specifier: ^2.0.12
version: 2.0.12(hono@4.12.32)
'@standardserver/core':
specifier: latest
@@ -223,7 +245,7 @@ importers:
specifier: latest
version: link:../../packages/fetch
'@types/node':
- specifier: ^26.0.0
+ specifier: ^26.1.2
version: 26.1.2
tsx:
specifier: ^4.22.4
@@ -241,7 +263,7 @@ importers:
specifier: latest
version: link:../../packages/node
'@types/node':
- specifier: ^26.0.0
+ specifier: ^26.1.2
version: 26.1.2
tsx:
specifier: ^4.22.4
@@ -1201,6 +1223,9 @@ packages:
peerDependencies:
eslint: ^9.0.0 || ^10.0.0
+ '@types/aws-lambda@8.10.162':
+ resolution: {integrity: sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==}
+
'@types/bun@1.3.14':
resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
@@ -4362,6 +4387,8 @@ snapshots:
estraverse: 5.3.0
picomatch: 4.0.5
+ '@types/aws-lambda@8.10.162': {}
+
'@types/bun@1.3.14':
dependencies:
bun-types: 1.3.14