diff --git a/.changeset/dull-crabs-kiss.md b/.changeset/dull-crabs-kiss.md new file mode 100644 index 00000000000..2de89acddb1 --- /dev/null +++ b/.changeset/dull-crabs-kiss.md @@ -0,0 +1,6 @@ +--- +'@whatwg-node/node-fetch': patch +'@whatwg-node/server': patch +--- + +Performance improvements diff --git a/packages/node-fetch/src/Body.ts b/packages/node-fetch/src/Body.ts index e2b296ca697..7a514dfa92d 100644 --- a/packages/node-fetch/src/Body.ts +++ b/packages/node-fetch/src/Body.ts @@ -12,6 +12,8 @@ enum BodyInitType { ArrayBuffer = 'ArrayBuffer', String = 'String', Readable = 'Readable', + Buffer = 'Buffer', + Uint8Array = 'Uint8Array', } export type BodyPonyfillInit = @@ -42,7 +44,6 @@ export interface PonyfillBodyOptions { export class PonyfillBody implements Body { bodyUsed = false; - private _body: PonyfillReadableStream | null = null; contentType: string | null = null; contentLength: number | null = null; @@ -50,8 +51,8 @@ export class PonyfillBody implements Body { private bodyInit: BodyPonyfillInit | null, private options: PonyfillBodyOptions = {}, ) { - const { body, contentType, contentLength, bodyType } = processBodyInit(bodyInit); - this._body = body; + const { bodyFactory, contentType, contentLength, bodyType } = processBodyInit(bodyInit); + this._bodyFactory = bodyFactory; this.contentType = contentType; this.contentLength = contentLength; this.bodyType = bodyType; @@ -59,11 +60,24 @@ export class PonyfillBody implements Body { private bodyType?: BodyInitType; + private _bodyFactory: () => PonyfillReadableStream | null = () => null; + private _generatedBody: PonyfillReadableStream | null = null; + + private generateBody(): PonyfillReadableStream | null { + if (this._generatedBody) { + return this._generatedBody; + } + const body = this._bodyFactory(); + this._generatedBody = body; + return body; + } + public get body(): PonyfillReadableStream | null { - if (this._body != null) { - const ponyfillReadableStream = this._body; - const readable = this._body.readable; - return new Proxy(this._body.readable as any, { + const _body = this.generateBody(); + if (_body != null) { + const ponyfillReadableStream = _body; + const readable = _body.readable; + return new Proxy(_body.readable as any, { get(_, prop) { if (prop in ponyfillReadableStream) { const ponyfillReadableStreamProp: any = ponyfillReadableStream[prop]; @@ -89,6 +103,9 @@ export class PonyfillBody implements Body { if (this.bodyType === BodyInitType.ArrayBuffer) { return this.bodyInit as ArrayBuffer; } + if (this.bodyType === BodyInitType.Uint8Array || this.bodyType === BodyInitType.Buffer) { + return (this.bodyInit as Uint8Array).buffer; + } const blob = await this.blob(); return blob.arrayBuffer(); } @@ -97,13 +114,33 @@ export class PonyfillBody implements Body { if (this.bodyType === BodyInitType.Blob) { return this.bodyInit as PonyfillBlob; } + if ( + this.bodyType === BodyInitType.String || + this.bodyType === BodyInitType.Buffer || + this.bodyType === BodyInitType.Uint8Array + ) { + const bodyInitTyped = this.bodyInit as string | Buffer | Uint8Array; + return new PonyfillBlob([bodyInitTyped], { + type: this.contentType || '', + }); + } + if (this.bodyType === BodyInitType.ArrayBuffer) { + const bodyInitTyped = this.bodyInit as ArrayBuffer; + const buf = Buffer.from(bodyInitTyped, undefined, bodyInitTyped.byteLength); + return new PonyfillBlob([buf], { + type: this.contentType || '', + }); + } const chunks: Uint8Array[] = []; - if (this._body) { - for await (const chunk of this._body.readable) { + const _body = this.generateBody(); + if (_body) { + for await (const chunk of _body.readable) { chunks.push(chunk); } } - return new PonyfillBlob(chunks); + return new PonyfillBlob(chunks, { + type: this.contentType || '', + }); } formData(opts?: { formDataLimits: FormDataLimits }): Promise { @@ -111,7 +148,8 @@ export class PonyfillBody implements Body { return Promise.resolve(this.bodyInit as PonyfillFormData); } const formData = new PonyfillFormData(); - if (this._body == null) { + const _body = this.generateBody(); + if (_body == null) { return Promise.resolve(formData); } const formDataLimits = { @@ -169,10 +207,28 @@ export class PonyfillBody implements Body { bb.on('error', err => { reject(err); }); - this._body?.readable.pipe(bb); + _body?.readable.pipe(bb); }); } + async buffer(): Promise { + if (this.bodyType === BodyInitType.Buffer) { + return this.bodyInit as Buffer; + } + if (this.bodyType === BodyInitType.Uint8Array || this.bodyType === BodyInitType.ArrayBuffer) { + const bodyInitTyped = this.bodyInit as Uint8Array | ArrayBuffer; + const buffer = Buffer.from( + bodyInitTyped, + 'byteOffset' in bodyInitTyped ? bodyInitTyped.byteOffset : undefined, + bodyInitTyped.byteLength, + ); + return buffer; + } + const blob = await this.blob(); + const arrayBuffer = await blob.arrayBuffer(); + return Buffer.from(arrayBuffer, undefined, arrayBuffer.byteLength); + } + async json(): Promise { const text = await this.text(); return JSON.parse(text); @@ -182,6 +238,18 @@ export class PonyfillBody implements Body { if (this.bodyType === BodyInitType.String) { return this.bodyInit as string; } + if (this.bodyType === BodyInitType.Buffer) { + return (this.bodyInit as Buffer).toString('utf-8'); + } + if (this.bodyType === BodyInitType.ArrayBuffer || this.bodyType === BodyInitType.Uint8Array) { + const bodyInitTyped = this.bodyInit as ArrayBuffer | Uint8Array; + const buffer = Buffer.from( + bodyInitTyped, + 'byteOffset' in bodyInitTyped ? bodyInitTyped.byteOffset : undefined, + bodyInitTyped.byteLength, + ); + return buffer.toString('utf-8'); + } const blob = await this.blob(); return blob.text(); } @@ -191,118 +259,156 @@ function processBodyInit(bodyInit: BodyPonyfillInit | null): { bodyType?: BodyInitType; contentType: string | null; contentLength: number | null; - body: PonyfillReadableStream | null; + bodyFactory(): PonyfillReadableStream | null; } { if (bodyInit == null) { return { - body: null, + bodyFactory: () => null, contentType: null, contentLength: null, }; } if (typeof bodyInit === 'string') { - const buffer = Buffer.from(bodyInit); - const readable = Readable.from(buffer); - const body = new PonyfillReadableStream(readable); return { bodyType: BodyInitType.String, contentType: 'text/plain;charset=UTF-8', - contentLength: buffer.length, - body, + contentLength: bodyInit.length, + bodyFactory() { + const buffer = Buffer.from(bodyInit); + const readable = Readable.from(buffer); + return new PonyfillReadableStream(readable); + }, }; } if (bodyInit instanceof PonyfillReadableStream) { return { bodyType: BodyInitType.ReadableStream, - body: bodyInit, + bodyFactory: () => bodyInit, contentType: null, contentLength: null, }; } if (bodyInit instanceof PonyfillBlob) { - const readable = bodyInit.stream(); - const body = new PonyfillReadableStream(readable); return { bodyType: BodyInitType.Blob, contentType: bodyInit.type, contentLength: bodyInit.size, - body, + bodyFactory() { + return bodyInit.stream(); + }, }; } if (bodyInit instanceof PonyfillFormData) { const boundary = Math.random().toString(36).substr(2); const contentType = `multipart/form-data; boundary=${boundary}`; - const body = bodyInit.stream(boundary); return { bodyType: BodyInitType.FormData, contentType, contentLength: null, - body, + bodyFactory: () => bodyInit.stream(boundary), + }; + } + if (bodyInit instanceof Buffer) { + const contentLength = bodyInit.length; + return { + bodyType: BodyInitType.Buffer, + contentLength, + contentType: null, + bodyFactory() { + const readable = Readable.from(bodyInit); + const body = new PonyfillReadableStream(readable); + return body; + }, + }; + } + if (bodyInit instanceof Uint8Array) { + const contentLength = bodyInit.byteLength; + return { + bodyType: BodyInitType.Uint8Array, + contentLength, + contentType: null, + bodyFactory() { + const readable = Readable.from(bodyInit); + const body = new PonyfillReadableStream(readable); + return body; + }, }; } if ('buffer' in bodyInit) { const contentLength = bodyInit.byteLength; - const buffer = Buffer.from(bodyInit.buffer, bodyInit.byteOffset, bodyInit.byteLength); - const readable = Readable.from(buffer); - const body = new PonyfillReadableStream(readable); return { contentLength, contentType: null, - body, + bodyFactory() { + const buffer = Buffer.from(bodyInit.buffer, bodyInit.byteOffset, bodyInit.byteLength); + const readable = Readable.from(buffer); + const body = new PonyfillReadableStream(readable); + return body; + }, }; } if (bodyInit instanceof ArrayBuffer) { const contentLength = bodyInit.byteLength; - const buffer = Buffer.from(bodyInit, undefined, bodyInit.byteLength); - const readable = Readable.from(buffer); - const body = new PonyfillReadableStream(readable); return { bodyType: BodyInitType.ArrayBuffer, contentType: null, contentLength, - body, + bodyFactory() { + const buffer = Buffer.from(bodyInit, undefined, bodyInit.byteLength); + const readable = Readable.from(buffer); + const body = new PonyfillReadableStream(readable); + return body; + }, }; } if (bodyInit instanceof Readable) { - const body = new PonyfillReadableStream(bodyInit); return { bodyType: BodyInitType.Readable, contentType: null, contentLength: null, - body, + bodyFactory() { + const body = new PonyfillReadableStream(bodyInit); + return body; + }, }; } if ('stream' in bodyInit) { - const bodyStream = bodyInit.stream(); - const body = new PonyfillReadableStream(bodyStream); return { contentType: bodyInit.type, contentLength: bodyInit.size, - body, + bodyFactory() { + const bodyStream = bodyInit.stream(); + const body = new PonyfillReadableStream(bodyStream); + return body; + }, }; } if (bodyInit instanceof URLSearchParams) { const contentType = 'application/x-www-form-urlencoded;charset=UTF-8'; - const body = new PonyfillReadableStream(Readable.from(bodyInit.toString())); return { bodyType: BodyInitType.String, contentType, contentLength: null, - body, + bodyFactory() { + const body = new PonyfillReadableStream(Readable.from(bodyInit.toString())); + return body; + }, }; } if ('forEach' in bodyInit) { - const formData = new PonyfillFormData(); - bodyInit.forEach((value, key) => { - formData.append(key, value); - }); const boundary = Math.random().toString(36).substr(2); const contentType = `multipart/form-data; boundary=${boundary}`; - const body = formData.stream(boundary); return { contentType, contentLength: null, - body, + bodyFactory() { + const formData = new PonyfillFormData(); + bodyInit.forEach((value, key) => { + formData.append(key, value); + }); + const body = formData.stream(boundary); + return body; + }, }; } diff --git a/packages/node-fetch/src/Response.ts b/packages/node-fetch/src/Response.ts index 7743057dd45..62a11e32bca 100644 --- a/packages/node-fetch/src/Response.ts +++ b/packages/node-fetch/src/Response.ts @@ -20,6 +20,24 @@ export class PonyfillResponse extends PonyfillBody implement this.redirected = init.redirected || false; this.type = init.type || 'default'; } + + const contentTypeInHeaders = this.headers.get('content-type'); + if (!contentTypeInHeaders) { + if (this.contentType) { + this.headers.set('content-type', this.contentType); + } + } else { + this.contentType = contentTypeInHeaders; + } + + const contentLengthInHeaders = this.headers.get('content-length'); + if (!contentLengthInHeaders) { + if (this.contentLength) { + this.headers.set('content-length', this.contentLength.toString()); + } + } else { + this.contentLength = parseInt(contentLengthInHeaders, 10); + } } headers: Headers = new PonyfillHeaders(); diff --git a/packages/node-fetch/src/fetch.ts b/packages/node-fetch/src/fetch.ts index f5dd7bfe9a2..318fa4f212e 100644 --- a/packages/node-fetch/src/fetch.ts +++ b/packages/node-fetch/src/fetch.ts @@ -4,6 +4,7 @@ import { request as httpsRequest } from 'https'; import { Readable } from 'stream'; import { fileURLToPath } from 'url'; import { PonyfillAbortError } from './AbortError'; +import { PonyfillBlob } from './Blob'; import { PonyfillRequest, RequestPonyfillInit } from './Request'; import { PonyfillResponse } from './Response'; import { getHeadersObj } from './utils'; @@ -47,12 +48,10 @@ export function fetchPonyfill( if (mimeType.endsWith(BASE64_SUFFIX)) { const buffer = Buffer.from(data, 'base64'); const realMimeType = mimeType.slice(0, -BASE64_SUFFIX.length); - const response = new PonyfillResponse(buffer, { + const file = new PonyfillBlob([buffer], { type: realMimeType }); + const response = new PonyfillResponse(file, { status: 200, statusText: 'OK', - headers: { - 'content-type': realMimeType, - }, }); resolve(response); return; diff --git a/packages/node-fetch/tests/non-http-fetch.spec.ts b/packages/node-fetch/tests/non-http-fetch.spec.ts index d3e08603e44..e7d9e94d17f 100644 --- a/packages/node-fetch/tests/non-http-fetch.spec.ts +++ b/packages/node-fetch/tests/non-http-fetch.spec.ts @@ -15,6 +15,7 @@ describe('data uris', () => { const res = await fetchPonyfill(b64); expect(res.status).toBe(200); expect(res.headers.get('Content-Type')).toBe('image/gif'); + expect(res.headers.get('Content-Length')).toBe('35'); const buf = await res.arrayBuffer(); expect(buf.byteLength).toBe(35); expect(buf).toBeInstanceOf(ArrayBuffer); diff --git a/packages/server/src/createServerAdapter.ts b/packages/server/src/createServerAdapter.ts index 2efa3cfa904..85037fbd3c0 100644 --- a/packages/server/src/createServerAdapter.ts +++ b/packages/server/src/createServerAdapter.ts @@ -83,7 +83,7 @@ function createServerAdapter< }; const response = await handleNodeRequest(nodeRequest, defaultServerContext as any, ...ctx); if (response) { - await sendNodeResponse(response, serverResponse); + await sendNodeResponse(response, serverResponse, nodeRequest); } else { await new Promise(resolve => { serverResponse.statusCode = 404; diff --git a/packages/server/src/utils.ts b/packages/server/src/utils.ts index 701ad7a4c93..dfc34a00d9a 100644 --- a/packages/server/src/utils.ts +++ b/packages/server/src/utils.ts @@ -169,32 +169,65 @@ export function isFetchEvent(event: any): event is FetchEvent { return event != null && event.request != null && event.respondWith != null; } +function configureSocket(rawRequest: NodeRequest) { + rawRequest?.socket?.setTimeout?.(0); + rawRequest?.socket?.setNoDelay?.(true); + rawRequest?.socket?.setKeepAlive?.(true); +} + export async function sendNodeResponse( - { headers, status, statusText, body }: Response, + fetchResponse: Response, serverResponse: NodeResponse, + nodeRequest: NodeRequest, ) { - headers.forEach((value, name) => { + fetchResponse.headers.forEach((value, name) => { serverResponse.setHeader(name, value); }); - serverResponse.statusCode = status; - serverResponse.statusMessage = statusText; + serverResponse.statusCode = fetchResponse.status; + serverResponse.statusMessage = fetchResponse.statusText; // eslint-disable-next-line no-async-promise-executor return new Promise(async resolve => { serverResponse.once('close', resolve); - if (body == null) { + // Our Node-fetch enhancements + + if ( + 'bodyType' in fetchResponse && + fetchResponse.bodyType != null && + (fetchResponse.bodyType === 'String' || fetchResponse.bodyType === 'Uint8Array') + ) { + // @ts-expect-error http and http2 writes are actually compatible + serverResponse.write(fetchResponse.bodyInit); + serverResponse.end(); + return; + } + + // Other fetch implementations + const fetchBody = fetchResponse.body; + if (fetchBody == null) { serverResponse.end(); - } else if (body[Symbol.toStringTag] === 'Uint8Array') { + return; + } + + if (fetchBody[Symbol.toStringTag] === 'Uint8Array') { serverResponse // @ts-expect-error http and http2 writes are actually compatible - .write(body); + .write(fetchBody); serverResponse.end(); - } else if (isReadable(body)) { + return; + } + + configureSocket(nodeRequest); + + if (isReadable(fetchBody)) { serverResponse.once('close', () => { - body.destroy(); + fetchBody.destroy(); }); - body.pipe(serverResponse); - } else if (isAsyncIterable(body)) { - for await (const chunk of body as AsyncIterable) { + fetchBody.pipe(serverResponse); + return; + } + + if (isAsyncIterable(fetchBody)) { + for await (const chunk of fetchBody as AsyncIterable) { if ( !serverResponse // @ts-expect-error http and http2 writes are actually compatible diff --git a/packages/server/test/node.spec.ts b/packages/server/test/node.spec.ts index 7b721af7b34..161f40091af 100644 --- a/packages/server/test/node.spec.ts +++ b/packages/server/test/node.spec.ts @@ -190,6 +190,7 @@ describe('http2', () => { "data": "Hey there!", "headers": { ":status": 418, + "content-length": "10", "content-type": "text/plain;charset=UTF-8", "x-is-this-http2": "yes", Symbol(nodejs.http2.sensitiveHeaders): [],