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 + +
+ + codecov + + + weekly downloads + + + CodSpeed + + + MIT License + + + Discord + + + Ask DeepWiki + +
+ +`@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 + + + + + +
ScreenshotOne.com
ScreenshotOne.com
+ +### 🥈 Silver Sponsor + + + + + +
村上さん
村上さん
+ +### Generous Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
christ12938
christ12938
Ryan Soderberg
Ryan Soderberg
shota
shota
Ellis Driscoll
Ellis Driscoll
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
Robbe Vaes
Robbe Vaes
Aidan Sunbury
Aidan Sunbury
soonoo
soonoo
Kevin Porten
Kevin Porten
Denis
Denis
Christopher Kapic
Christopher Kapic
Tom Ballinger
Tom Ballinger
Sam
Sam
Titoine
Titoine
Igor Makowski
Igor Makowski
hanayashiki
hanayashiki
Lev Dubinets
Lev Dubinets
Kelly Peilin Chan
Kelly Peilin Chan
Alex
Alex
Andrey Gubanov
Andrey Gubanov
+ +### Past Sponsors + +

+ Maxie + Stijn Timmer + あわわわとーにゅ + Zuplo + motopods + Francisco Hermida + Théo LUDWIG + Abhay Ramesh + shr.ink oü + 0x4e32 + Ryuz + happyboy + yicchi + Saksham + Roman Hrynevych + rokitg + Omar Khatib + Yu-Sabo + Bapusaheb Patil + grim + Nelson Lai + Lê Cao Nguyên + Robert Soriano + Andrew Peters + Ryan Vogel + SKostyukovich + Peter Adam + Fabworks + Novak Antonijevic + Laduni Estu Syalwa + Chen, Zhi-Yuan + Illarion Koperski + Anees Iqbal + Sefa Eyeoglu + natt + Adam Tkaczyk + plancraft + Nicholas +

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