From 137c47b5d5de35c134262aadf24928e9d1fc1403 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 29 Sep 2026 20:19:35 +0700 Subject: [PATCH 1/3] perf(aws-lambda): decode json and url-search-params bodies without a UTF-8 round trip Text hints now build the string straight from the event body instead of encoding it to bytes and decoding it back. Leading BOM stripping and lone surrogate replacement match TextDecoder, as before. Binary hints still copy base64 bytes out of Node's Buffer pool. --- benches/aws-lambda-body.bench.ts | 47 ++++++++++++++++++++++++++++ package.json | 1 + packages/aws-lambda/src/body.test.ts | 29 +++++++++++++++++ packages/aws-lambda/src/body.ts | 38 +++++++++++++++++----- pnpm-lock.yaml | 3 ++ 5 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 benches/aws-lambda-body.bench.ts diff --git a/benches/aws-lambda-body.bench.ts b/benches/aws-lambda-body.bench.ts new file mode 100644 index 00000000..4b558971 --- /dev/null +++ b/benches/aws-lambda-body.bench.ts @@ -0,0 +1,47 @@ +import type { APIGatewayProxyEvent } from '@standard-server/aws-lambda' +import { Buffer } from 'node:buffer' +import { toStandardBody } from '@standard-server/aws-lambda' +import { stringifyJSON } from '@standard-server/shared' +import { bench, describe } from 'vitest' + +/** About 1MB of stringified JSON, `name` switches it between a one-byte and a two-byte string. */ +function createJson(name: string): string { + return stringifyJSON(Array.from({ length: 9000 }, (_, i) => ({ + id: i, + name: `${name}-${i}`, + email: `user-${i}@example.com`, + tags: ['a', 'b', 'c'], + active: i % 2 === 0, + })))! +} + +const JSON_1MB = createJson('user') +const JSON_1MB_NON_ASCII = createJson('ユーザー') + +function jsonEvent(body: string, isBase64Encoded = false): APIGatewayProxyEvent { + return { + httpMethod: 'POST', + path: '/', + body: isBase64Encoded ? Buffer.from(body).toString('base64') : body, + isBase64Encoded, + headers: { 'content-type': 'application/json' }, + } +} + +const EVENT_1MB = jsonEvent(JSON_1MB) +const EVENT_1MB_BASE64 = jsonEvent(JSON_1MB, true) +const EVENT_1MB_NON_ASCII = jsonEvent(JSON_1MB_NON_ASCII) + +describe('aws-lambda toStandardBody json', () => { + bench('1MB', async () => { + await toStandardBody(EVENT_1MB) + }) + + bench('1MB base64', async () => { + await toStandardBody(EVENT_1MB_BASE64) + }) + + bench('1MB non-ascii', async () => { + await toStandardBody(EVENT_1MB_NON_ASCII) + }) +}) diff --git a/package.json b/package.json index 3c714025..8343532d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@codspeed/vitest-plugin": "^5.7.1", "@hono/node-server": "^2.0.12", "@remix-run/node-fetch-server": "^0.14.0", + "@standard-server/aws-lambda": "workspace:*", "@standard-server/core": "workspace:*", "@standard-server/fastify": "workspace:*", "@standard-server/fetch": "workspace:*", diff --git a/packages/aws-lambda/src/body.test.ts b/packages/aws-lambda/src/body.test.ts index e0409200..4f8de144 100644 --- a/packages/aws-lambda/src/body.test.ts +++ b/packages/aws-lambda/src/body.test.ts @@ -66,6 +66,28 @@ describe('toStandardBody', () => { }))).resolves.toEqual({ foo: 'bar' }) }) + it('strips a leading BOM from json', async () => { + await expect(toStandardBody(event({ + body: '{"foo":"bar"}', + multiValueHeaders: { 'Content-Type': ['application/json'] }, + }))).resolves.toEqual({ foo: 'bar' }) + }) + + it('strips a leading BOM from 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('replaces lone surrogates in json with U+FFFD', async () => { + await expect(toStandardBody(event({ + body: '{"foo":"\uD800","bar":"\uDC00😀"}', + multiValueHeaders: { 'Content-Type': ['application/json'] }, + }))).resolves.toEqual({ foo: '�', bar: '�😀' }) + }) + it('parses empty json as undefined', async () => { await expect(toStandardBody(event({ body: '', @@ -81,6 +103,13 @@ describe('toStandardBody', () => { multiValueHeaders: { 'Content-Type': ['application/x-www-form-urlencoded'] }, }))).resolves.toEqual(new URLSearchParams('foo=bar&baz=qux')) }) + + it('strips a leading BOM from url-encoded forms', async () => { + await expect(toStandardBody(event({ + body: 'foo=bar', + multiValueHeaders: { 'Content-Type': ['application/x-www-form-urlencoded'] }, + }))).resolves.toEqual(new URLSearchParams('foo=bar')) + }) }) describe('form-data', () => { diff --git a/packages/aws-lambda/src/body.ts b/packages/aws-lambda/src/body.ts index 572a3afc..cf3661cd 100644 --- a/packages/aws-lambda/src/body.ts +++ b/packages/aws-lambda/src/body.ts @@ -1,3 +1,5 @@ +/// + import type { StandardBody, StandardBodyHint } from '@standard-server/core' import type { AnyAPIGatewayProxyEvent } from './types' import { Buffer } from 'node:buffer' @@ -31,26 +33,27 @@ export async function toStandardBody( return undefined } + if (hint === 'json') { + return parseEmptyableJSON(_eventBodyToString(event)) + } + + if (hint === 'url-search-params') { + return new URLSearchParams(_eventBodyToString(event)) + } + const bytes: Uint8Array = typeof event.body !== 'string' ? new Uint8Array() : event.isBase64Encoded + // copy out of Node's shared Buffer pool so `.buffer` exposes only this body ? new Uint8Array(Buffer.from(event.body, 'base64')) : new TextEncoder().encode(event.body) - if (hint === 'json') { - return parseEmptyableJSON(new TextDecoder().decode(bytes)) - } - const contentType = flattenStandardHeader(getEventHeader(event, 'content-type')) if (hint === 'form-data') { return _bytesToFormData(bytes, contentType) } - if (hint === 'url-search-params') { - return new URLSearchParams(new TextDecoder().decode(bytes)) - } - if (hint === 'event-stream') { return toAsyncIteratorObject(_bytesToReadableStream(bytes)) } @@ -67,6 +70,25 @@ export async function toStandardBody( return _bytesToReadableStream(bytes) } +/** + * Decodes the body as `TextDecoder` would decode its UTF-8 bytes, without materializing + * them for a plain body: lone surrogates become U+FFFD and a leading BOM is stripped. + */ +function _eventBodyToString(event: AnyAPIGatewayProxyEvent): string { + if (typeof event.body !== 'string') { + return '' + } + + if (event.isBase64Encoded) { + return new TextDecoder().decode(Buffer.from(event.body, 'base64')) + } + + // one-byte strings cannot hold surrogates, so this returns them without scanning + const string = event.body.toWellFormed() + + return string.charCodeAt(0) === 0xFEFF ? string.slice(1) : string +} + function _bytesToFormData(bytes: Uint8Array, contentType: string | undefined): Promise { const response = new Response(bytes, { headers: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 904afd5a..98ea1392 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: '@remix-run/node-fetch-server': specifier: ^0.14.0 version: 0.14.1 + '@standard-server/aws-lambda': + specifier: workspace:* + version: link:packages/aws-lambda '@standard-server/core': specifier: workspace:* version: link:packages/core From 5d0a4aa1dad74e0816bc5b63c547f2518c615db2 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 29 Sep 2026 20:21:11 +0700 Subject: [PATCH 2/3] test(aws-lambda): write BOM and surrogate test strings as escapes --- packages/aws-lambda/src/body.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/aws-lambda/src/body.test.ts b/packages/aws-lambda/src/body.test.ts index e96b85f1..7d0271a8 100644 --- a/packages/aws-lambda/src/body.test.ts +++ b/packages/aws-lambda/src/body.test.ts @@ -68,14 +68,14 @@ describe('toStandardBody', () => { it('strips a leading BOM from json', async () => { await expect(toStandardBody(event({ - body: '{"foo":"bar"}', + body: '\uFEFF{"foo":"bar"}', multiValueHeaders: { 'Content-Type': ['application/json'] }, }))).resolves.toEqual({ foo: 'bar' }) }) it('strips a leading BOM from base64-encoded json', async () => { await expect(toStandardBody(event({ - body: Buffer.from('{"foo":"bar"}').toString('base64'), + body: Buffer.from('\uFEFF{"foo":"bar"}').toString('base64'), isBase64Encoded: true, multiValueHeaders: { 'Content-Type': ['application/json'] }, }))).resolves.toEqual({ foo: 'bar' }) @@ -83,9 +83,9 @@ describe('toStandardBody', () => { it('replaces lone surrogates in json with U+FFFD', async () => { await expect(toStandardBody(event({ - body: '{"foo":"\uD800","bar":"\uDC00😀"}', + body: '{"foo":"\uD800","bar":"\uDC00\uD83D\uDE00"}', multiValueHeaders: { 'Content-Type': ['application/json'] }, - }))).resolves.toEqual({ foo: '�', bar: '�😀' }) + }))).resolves.toEqual({ foo: '\uFFFD', bar: '\uFFFD\uD83D\uDE00' }) }) it('parses empty json as undefined', async () => { @@ -106,7 +106,7 @@ describe('toStandardBody', () => { it('strips a leading BOM from url-encoded forms', async () => { await expect(toStandardBody(event({ - body: 'foo=bar', + body: '\uFEFFfoo=bar', multiValueHeaders: { 'Content-Type': ['application/x-www-form-urlencoded'] }, }))).resolves.toEqual(new URLSearchParams('foo=bar')) }) From 5ff83817d80bef876007763100676ca87925f1ea Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 29 Sep 2026 20:55:13 +0700 Subject: [PATCH 3/3] test: cover leading BOM stripping in json and url-search-params bodies Adds tests to the aws-lambda, node and fetch adapters that a leading UTF-8 BOM is stripped before json and url-search-params bodies are parsed. The adapters already strip it, so these only guard existing behaviour. Also drops the earlier aws-lambda body decoding change, its bench and the tsconfig lib entries it needed, leaving the branch as tests only. --- benches/aws-lambda-body.bench.ts | 47 ---------------------------- package.json | 1 - packages/aws-lambda/src/body.test.ts | 7 ----- packages/aws-lambda/src/body.ts | 38 +++++----------------- packages/fetch/src/body.test.ts | 24 ++++++++++++++ packages/node/src/body.test.ts | 28 +++++++++++++++++ pnpm-lock.yaml | 3 -- 7 files changed, 60 insertions(+), 88 deletions(-) delete mode 100644 benches/aws-lambda-body.bench.ts diff --git a/benches/aws-lambda-body.bench.ts b/benches/aws-lambda-body.bench.ts deleted file mode 100644 index 4b558971..00000000 --- a/benches/aws-lambda-body.bench.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { APIGatewayProxyEvent } from '@standard-server/aws-lambda' -import { Buffer } from 'node:buffer' -import { toStandardBody } from '@standard-server/aws-lambda' -import { stringifyJSON } from '@standard-server/shared' -import { bench, describe } from 'vitest' - -/** About 1MB of stringified JSON, `name` switches it between a one-byte and a two-byte string. */ -function createJson(name: string): string { - return stringifyJSON(Array.from({ length: 9000 }, (_, i) => ({ - id: i, - name: `${name}-${i}`, - email: `user-${i}@example.com`, - tags: ['a', 'b', 'c'], - active: i % 2 === 0, - })))! -} - -const JSON_1MB = createJson('user') -const JSON_1MB_NON_ASCII = createJson('ユーザー') - -function jsonEvent(body: string, isBase64Encoded = false): APIGatewayProxyEvent { - return { - httpMethod: 'POST', - path: '/', - body: isBase64Encoded ? Buffer.from(body).toString('base64') : body, - isBase64Encoded, - headers: { 'content-type': 'application/json' }, - } -} - -const EVENT_1MB = jsonEvent(JSON_1MB) -const EVENT_1MB_BASE64 = jsonEvent(JSON_1MB, true) -const EVENT_1MB_NON_ASCII = jsonEvent(JSON_1MB_NON_ASCII) - -describe('aws-lambda toStandardBody json', () => { - bench('1MB', async () => { - await toStandardBody(EVENT_1MB) - }) - - bench('1MB base64', async () => { - await toStandardBody(EVENT_1MB_BASE64) - }) - - bench('1MB non-ascii', async () => { - await toStandardBody(EVENT_1MB_NON_ASCII) - }) -}) diff --git a/package.json b/package.json index 8343532d..3c714025 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "@codspeed/vitest-plugin": "^5.7.1", "@hono/node-server": "^2.0.12", "@remix-run/node-fetch-server": "^0.14.0", - "@standard-server/aws-lambda": "workspace:*", "@standard-server/core": "workspace:*", "@standard-server/fastify": "workspace:*", "@standard-server/fetch": "workspace:*", diff --git a/packages/aws-lambda/src/body.test.ts b/packages/aws-lambda/src/body.test.ts index 7d0271a8..01c1c0f8 100644 --- a/packages/aws-lambda/src/body.test.ts +++ b/packages/aws-lambda/src/body.test.ts @@ -81,13 +81,6 @@ describe('toStandardBody', () => { }))).resolves.toEqual({ foo: 'bar' }) }) - it('replaces lone surrogates in json with U+FFFD', async () => { - await expect(toStandardBody(event({ - body: '{"foo":"\uD800","bar":"\uDC00\uD83D\uDE00"}', - multiValueHeaders: { 'Content-Type': ['application/json'] }, - }))).resolves.toEqual({ foo: '\uFFFD', bar: '\uFFFD\uD83D\uDE00' }) - }) - it('parses empty json as undefined', async () => { await expect(toStandardBody(event({ body: '', diff --git a/packages/aws-lambda/src/body.ts b/packages/aws-lambda/src/body.ts index 15d4bf0b..481b98b7 100644 --- a/packages/aws-lambda/src/body.ts +++ b/packages/aws-lambda/src/body.ts @@ -1,5 +1,3 @@ -/// - import type { StandardBody, StandardBodyHint } from '@standard-server/core' import type { AnyAPIGatewayProxyEvent } from './types' import { Buffer } from 'node:buffer' @@ -29,27 +27,26 @@ export async function toStandardBody( return undefined } - if (hint === 'json') { - return parseEmptyableJSON(_eventBodyToString(event)) - } - - if (hint === 'url-search-params') { - return new URLSearchParams(_eventBodyToString(event)) - } - const bytes: Uint8Array = typeof event.body !== 'string' ? new Uint8Array() : event.isBase64Encoded - // copy out of Node's shared Buffer pool so `.buffer` exposes only this body ? new Uint8Array(Buffer.from(event.body, 'base64')) : new TextEncoder().encode(event.body) + if (hint === 'json') { + return parseEmptyableJSON(new TextDecoder().decode(bytes)) + } + const contentType = flattenStandardHeader(headers['content-type']) if (hint === 'form-data') { return _bytesToFormData(bytes, contentType) } + if (hint === 'url-search-params') { + return new URLSearchParams(new TextDecoder().decode(bytes)) + } + if (hint === 'event-stream') { return toAsyncIteratorObject(_bytesToReadableStream(bytes)) } @@ -66,25 +63,6 @@ export async function toStandardBody( return _bytesToReadableStream(bytes) } -/** - * Decodes the body as `TextDecoder` would decode its UTF-8 bytes, without materializing - * them for a plain body: lone surrogates become U+FFFD and a leading BOM is stripped. - */ -function _eventBodyToString(event: AnyAPIGatewayProxyEvent): string { - if (typeof event.body !== 'string') { - return '' - } - - if (event.isBase64Encoded) { - return new TextDecoder().decode(Buffer.from(event.body, 'base64')) - } - - // one-byte strings cannot hold surrogates, so this returns them without scanning - const string = event.body.toWellFormed() - - return string.charCodeAt(0) === 0xFEFF ? string.slice(1) : string -} - function _bytesToFormData(bytes: Uint8Array, contentType: string | undefined): Promise { const response = new Response(bytes, { headers: { diff --git a/packages/fetch/src/body.test.ts b/packages/fetch/src/body.test.ts index 03192c2a..bc676bf6 100644 --- a/packages/fetch/src/body.test.ts +++ b/packages/fetch/src/body.test.ts @@ -73,6 +73,18 @@ describe('toStandardBody', () => { expect(await toStandardBody(request)).toEqual({ foo: 'bar' }) }) + it('json strips a leading BOM', async () => { + const request = new Request('https://example.com', { + method: 'POST', + body: '\uFEFF{"foo":"bar"}', + headers: { + 'standard-server': 'json', + }, + }) + + expect(await toStandardBody(request)).toEqual({ foo: 'bar' }) + }) + it('async iterator object', async () => { const stream = new ReadableStream({ async pull(controller) { @@ -130,6 +142,18 @@ describe('toStandardBody', () => { expect(await toStandardBody(request)).toEqual(new URLSearchParams('foo=bar&bar=baz')) }) + it('url-search-params strips a leading BOM', async () => { + const request = new Request('https://example.com', { + method: 'POST', + body: '\uFEFFfoo=bar', + headers: { + 'standard-server': 'url-search-params', + }, + }) + + expect(await toStandardBody(request)).toEqual(new URLSearchParams('foo=bar')) + }) + it('file/blob', async () => { const request = new Request('https://example.com', { method: 'POST', diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index 891faf50..4029c932 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -86,6 +86,20 @@ describe('toStandardBody', () => { expect(standardBody).toEqual({ foo: 'bar' }) }) + it('json strips a leading BOM', async () => { + let standardBody: StandardBody = {} as any + + await request(async (req: IncomingMessage, res: ServerResponse) => { + standardBody = await toStandardBody(req) + res.end() + }) + .post('/') + .set('standard-server', 'json') + .send('\uFEFF{"foo":"bar"}') + + expect(standardBody).toEqual({ foo: 'bar' }) + }) + it('async iterator object', async () => { let standardBody: any @@ -133,6 +147,20 @@ describe('toStandardBody', () => { expect(standardBody).toEqual(new URLSearchParams('foo=bar&bar=baz')) }) + it('url-search-params strips a leading BOM', async () => { + let standardBody: any + + await request(async (req: IncomingMessage, res: ServerResponse) => { + standardBody = await toStandardBody(req) + res.end() + }) + .post('/') + .set('standard-server', 'url-search-params') + .send('\uFEFFfoo=bar') + + expect(standardBody).toEqual(new URLSearchParams('foo=bar')) + }) + it('file/blob', async () => { let standardBody: any diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98ea1392..904afd5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,9 +178,6 @@ importers: '@remix-run/node-fetch-server': specifier: ^0.14.0 version: 0.14.1 - '@standard-server/aws-lambda': - specifier: workspace:* - version: link:packages/aws-lambda '@standard-server/core': specifier: workspace:* version: link:packages/core