diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index 9efbb23..bf578ce 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -2,6 +2,7 @@ import type { StandardBody } from '@standardserver/core' import type { IncomingMessage, ServerResponse } from 'node:http' import type { NodeHttpRequest } from './types' import { Buffer } from 'node:buffer' +import http2 from 'node:http2' import { Readable } from 'node:stream' import * as StandardServerModule from '@standardserver/core' import { toFetchHeaders } from '@standardserver/fetch' @@ -162,7 +163,7 @@ describe('toStandardBody', () => { }) describe('handle utf-8 characters split across stream chunks', () => { - function createChunkedIncomingMessage(method: string, contentType: string, chunks: Array): IncomingMessage { + function createChunkedIncomingMessage(method: string, contentType: string, chunks: Buffer[]): IncomingMessage { const request = Readable.from(chunks) as IncomingMessage request.method = method request.headers = { @@ -199,12 +200,116 @@ describe('toStandardBody', () => { const result = await toStandardBody(incomingMessage) expect(result).toEqual(new URLSearchParams('emoji=ļæ½')) }) + }) + + describe('http2', () => { + /** + * Runs a request through a real http2 server, so `toStandardBody` receives an + * `Http2ServerRequest` instead of an `IncomingMessage`. + */ + async function http2Roundtrip( + onTestFinished: (fn: () => Promise) => void, + headers: Record, + body: Buffer, + ): Promise<[standardBody: StandardBody, streamedBytes: Uint8Array | undefined]> { + let standardBody: StandardBody + let streamedBytes: Uint8Array | undefined + let error: unknown + + const server = http2.createServer(async (req, res) => { + try { + standardBody = await toStandardBody(req) + + // a streaming body must be drained while the request is still alive + if (standardBody instanceof ReadableStream) { + streamedBytes = new Uint8Array(await new Response(standardBody).arrayBuffer()) + } + } + catch (e) { + error = e + } + res.end() + }) + onTestFinished(() => new Promise(r => server.close(r))) + + await new Promise(r => server.listen(0, r)) + const port = (server.address() as any).port + + const client = http2.connect(`http://localhost:${port}`) + onTestFinished(async () => client.close()) + + await new Promise((resolve) => { + const stream = client.request({ ':method': 'POST', ':path': '/', ...headers }) + stream.end(body) + stream.on('response', () => { + stream.resume() + stream.on('end', () => resolve()) + }) + }) + + if (error !== undefined) { + throw error + } + + return [standardBody!, streamedBytes] + } + + it('json', async ({ onTestFinished }) => { + const [result] = await http2Roundtrip( + onTestFinished, + { 'content-type': 'application/json' }, + Buffer.from('{"emoji":"šŸ˜€"}'), + ) - it('json: string chunks', async () => { - const incomingMessage = createChunkedIncomingMessage('POST', 'application/json', ['{"emoji":"', 'šŸ˜€"}']) - const result = await toStandardBody(incomingMessage) expect(result).toEqual({ emoji: 'šŸ˜€' }) }) + + it('url-search-params', async ({ onTestFinished }) => { + const [result] = await http2Roundtrip( + onTestFinished, + { 'content-type': 'application/x-www-form-urlencoded' }, + Buffer.from('emoji=šŸ˜€'), + ) + + expect(result).toEqual(new URLSearchParams('emoji=šŸ˜€')) + }) + + it('form-data', async ({ onTestFinished }) => { + const [result] = await http2Roundtrip( + onTestFinished, + { 'content-type': 'multipart/form-data; boundary=X' }, + Buffer.from('--X\r\nContent-Disposition: form-data; name="emoji"\r\n\r\nšŸ˜€\r\n--X--\r\n'), + ) as [FormData, undefined] + + expect(result).toBeInstanceOf(FormData) + expect(result.get('emoji')).toBe('šŸ˜€') + }) + + it('file', async ({ onTestFinished }) => { + const body = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]) + + const [result] = await http2Roundtrip(onTestFinished, { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="foo.pdf"', + }, body) as [File, undefined] + + expect(result).toBeInstanceOf(File) + expect(result.name).toBe('foo.pdf') + expect(new Uint8Array(await result.arrayBuffer())).toEqual(new Uint8Array(body)) + }) + + it('octet-stream', async ({ onTestFinished }) => { + const body = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]) + + const [result, streamedBytes] = await http2Roundtrip( + onTestFinished, + { 'content-type': 'application/octet-stream' }, + body, + ) + + expect(result).toBeInstanceOf(ReadableStream) + expect(streamedBytes).toEqual(new Uint8Array(body)) + }) }) describe('edge case', () => { diff --git a/packages/node/src/body.ts b/packages/node/src/body.ts index 03c3002..cdbbc32 100644 --- a/packages/node/src/body.ts +++ b/packages/node/src/body.ts @@ -1,8 +1,7 @@ import type { StandardBody, StandardBodyHint, StandardHeaders } from '@standardserver/core' -import type { IncomingMessage } from 'node:http' +import type { Buffer } from 'node:buffer' import type { ToEventStreamOptions } from './event-stream' import type { NodeHttpRequest } from './types' -import { Buffer } from 'node:buffer' import { Readable } from 'node:stream' import { generateContentDisposition, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standardserver/core' import { isAsyncIteratorObject, parseEmptyableJSON, stringifyJSON } from '@standardserver/shared' @@ -72,8 +71,7 @@ export async function toStandardBody( return _streamToFile(req, fileName ?? 'blob', contentType ?? '') } - // TODO: support http2 - return Readable.toWeb(req as IncomingMessage) + return Readable.toWeb(req as Readable) } export interface ToNodeHttpBodyOptions { @@ -169,7 +167,7 @@ async function _streamToString(stream: Readable): Promise { let string = '' for await (const chunk of stream) { - string += decoder.decode(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), { stream: true }) + string += decoder.decode(chunk, { stream: true }) } // Flush any remaining bytes (e.g. incomplete multi-byte sequences)