From 145e46e8d11ddfddb3fbb5335a1a959cc63c0eba Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Sat, 20 Jul 2024 18:50:42 +0300 Subject: [PATCH] feat(node-fetch): `Body.bytes` and `Blob.bytes` --- .changeset/large-pumpkins-beg.md | 6 + packages/fetchache/src/index.ts | 17 ++- packages/node-fetch/src/Blob.ts | 126 +++++++++++++++--- packages/node-fetch/src/Body.ts | 44 ++++-- packages/node-fetch/tests/Body.spec.ts | 2 +- packages/node-fetch/tests/FormData.spec.ts | 2 +- .../node-fetch/tests/non-http-fetch.spec.ts | 4 +- 7 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 .changeset/large-pumpkins-beg.md diff --git a/.changeset/large-pumpkins-beg.md b/.changeset/large-pumpkins-beg.md new file mode 100644 index 00000000000..34f99ebb5a8 --- /dev/null +++ b/.changeset/large-pumpkins-beg.md @@ -0,0 +1,6 @@ +--- +'@whatwg-node/node-fetch': patch +'fetchache': patch +--- + +Implement `.bytes` method for `Blob` and `Body`, now `Uint8Array` is available with `bytes` format diff --git a/packages/fetchache/src/index.ts b/packages/fetchache/src/index.ts index 9b63c2c5761..05712b7af69 100644 --- a/packages/fetchache/src/index.ts +++ b/packages/fetchache/src/index.ts @@ -82,8 +82,7 @@ export function fetchFactory({ fetch, Response, cache }: FetchacheOptions): Fetc policyResponseFrom(revalidationResponse), ); - const newArrayBuffer = await revalidationResponse.arrayBuffer(); - const newBody: BodyInit = modified ? newArrayBuffer : bodyInit; + const newBody: BodyInit = modified ? await getBytesFromBody(revalidationResponse) : bodyInit; return storeResponseAndReturnClone( cache, @@ -113,11 +112,10 @@ export function fetchFactory({ fetch, Response, cache }: FetchacheOptions): Fetc ttl *= 2; } - const arrayBuffer = await response.arrayBuffer(); - const uint8array = new Uint8Array(arrayBuffer); + const bytes = await getBytesFromBody(response); const entry = { policy: policy.toObject(), - bytes: Array.from(uint8array), + bytes: Array.from(bytes), }; await cache.set(cacheKey, entry, { @@ -128,7 +126,7 @@ export function fetchFactory({ fetch, Response, cache }: FetchacheOptions): Fetc // body can only be used once. // To avoid https://github.com/bitinn/node-fetch/issues/151, we don't use // response.clone() but create a new response from the consumed body - return new Response(uint8array, response); + return new Response(bytes, response); } } @@ -178,3 +176,10 @@ export interface KeyValueCache { set(key: string, value: V, options?: KeyValueCacheSetOptions): Promise; delete(key: string): Promise; } + +function getBytesFromBody(body: Body) { + if ((body as any).bytes) { + return (body as any).bytes(); + } + return body.arrayBuffer().then(buffer => new Uint8Array(buffer)); +} diff --git a/packages/node-fetch/src/Blob.ts b/packages/node-fetch/src/Blob.ts index 09336545ab6..f2100aa44ac 100644 --- a/packages/node-fetch/src/Blob.ts +++ b/packages/node-fetch/src/Blob.ts @@ -31,10 +31,34 @@ function getBlobPartAsBuffer(blobPart: Exclude) { } } -function isBlob(obj: any): obj is Blob { +export function hasBufferMethod(obj: any): obj is { buffer(): Promise } { + return obj != null && obj.buffer != null; +} + +export function hasArrayBufferMethod(obj: any): obj is { arrayBuffer(): Promise } { return obj != null && obj.arrayBuffer != null; } +export function hasBytesMethod(obj: any): obj is { bytes(): Promise } { + return obj != null && obj.bytes != null; +} + +export function hasTextMethod(obj: any): obj is { text(): Promise } { + return obj != null && obj.text != null; +} + +export function hasSizeProperty(obj: any): obj is { size: number } { + return obj != null && typeof obj.size === 'number'; +} + +export function hasStreamMethod(obj: any): obj is { stream(): any } { + return obj != null && obj.stream != null; +} + +export function hasBlobSignature(obj: any): obj is Blob { + return obj != null && obj[Symbol.toStringTag] === 'Blob'; +} + // Will be removed after v14 reaches EOL // Needed because v14 doesn't have .stream() implemented export class PonyfillBlob implements Blob { @@ -48,28 +72,64 @@ export class PonyfillBlob implements Blob { this.type = options?.type || 'application/octet-stream'; this.encoding = options?.encoding || 'utf8'; this._size = options?.size || null; - if (blobParts.length === 1 && isBlob(blobParts[0])) { + if (blobParts.length === 1 && hasBlobSignature(blobParts[0])) { return blobParts[0] as PonyfillBlob; } } - arrayBuffer() { + _buffer: Buffer | null = null; + + buffer() { + if (this._buffer) { + return fakePromise(this._buffer); + } if (this.blobParts.length === 1) { const blobPart = this.blobParts[0]; - if (isBlob(blobPart)) { - return blobPart.arrayBuffer() as Promise; + if (hasBufferMethod(blobPart)) { + return blobPart.buffer().then(buf => { + this._buffer = buf; + return this._buffer; + }); + } + if (hasBytesMethod(blobPart)) { + return blobPart.bytes().then(bytes => { + this._buffer = Buffer.from(bytes); + return this._buffer; + }); + } + if (hasArrayBufferMethod(blobPart)) { + return blobPart.arrayBuffer().then(arrayBuf => { + this._buffer = Buffer.from(arrayBuf, undefined, blobPart.size); + return this._buffer; + }); } - return fakePromise(getBlobPartAsBuffer(blobPart)); + this._buffer = getBlobPartAsBuffer(blobPart); + return fakePromise(this._buffer); } + const jobs: Promise[] = []; const bufferChunks: Buffer[] = this.blobParts.map((blobPart, i) => { - if (isBlob(blobPart)) { + if (hasBufferMethod(blobPart)) { + jobs.push( + blobPart.buffer().then(buf => { + bufferChunks[i] = buf; + }), + ); + return undefined as any; + } else if (hasArrayBufferMethod(blobPart)) { jobs.push( blobPart.arrayBuffer().then(arrayBuf => { bufferChunks[i] = Buffer.from(arrayBuf, undefined, blobPart.size); }), ); return undefined as any; + } else if (hasBytesMethod(blobPart)) { + jobs.push( + blobPart.bytes().then(bytes => { + bufferChunks[i] = Buffer.from(bytes); + }), + ); + return undefined as any; } else { return getBlobPartAsBuffer(blobPart); } @@ -80,19 +140,36 @@ export class PonyfillBlob implements Blob { return fakePromise(Buffer.concat(bufferChunks, this._size || undefined)); } + arrayBuffer(): Promise { + return this.buffer(); + } + + _text: string | null = null; + text() { + if (this._text) { + return fakePromise(this._text); + } if (this.blobParts.length === 1) { const blobPart = this.blobParts[0]; if (typeof blobPart === 'string') { - return fakePromise(blobPart); + this._text = blobPart; + return fakePromise(this._text); } - if (isBlob(blobPart)) { - return blobPart.text(); + if (hasTextMethod(blobPart)) { + return blobPart.text().then(text => { + this._text = text; + return this._text; + }); } const buf = getBlobPartAsBuffer(blobPart); - return fakePromise(buf.toString(this.encoding)); + this._text = buf.toString(this.encoding); + return fakePromise(this._text); } - return this.arrayBuffer().then(buf => buf.toString(this.encoding)); + return this.buffer().then(buf => { + this._text = buf.toString(this.encoding); + return this._text; + }); } get size() { @@ -101,7 +178,7 @@ export class PonyfillBlob implements Blob { for (const blobPart of this.blobParts) { if (typeof blobPart === 'string') { this._size += Buffer.byteLength(blobPart); - } else if (isBlob(blobPart)) { + } else if (hasSizeProperty(blobPart)) { this._size += blobPart.size; } else if (isArrayBufferView(blobPart)) { this._size += blobPart.byteLength; @@ -114,7 +191,7 @@ export class PonyfillBlob implements Blob { stream(): any { if (this.blobParts.length === 1) { const blobPart = this.blobParts[0]; - if (isBlob(blobPart)) { + if (hasStreamMethod(blobPart)) { return blobPart.stream(); } const buf = getBlobPartAsBuffer(blobPart); @@ -125,6 +202,14 @@ export class PonyfillBlob implements Blob { }, }); } + if (this._buffer != null) { + return new PonyfillReadableStream({ + start: controller => { + controller.enqueue(this._buffer!); + controller.close(); + }, + }); + } let blobPartIterator: Iterator | undefined; return new PonyfillReadableStream({ start: controller => { @@ -141,7 +226,18 @@ export class PonyfillBlob implements Blob { return; } if (blobPart) { - if (isBlob(blobPart)) { + if (hasBufferMethod(blobPart)) { + return blobPart.buffer().then(buf => { + controller.enqueue(buf); + }); + } + if (hasBytesMethod(blobPart)) { + return blobPart.bytes().then(bytes => { + const buf = Buffer.from(bytes); + controller.enqueue(buf); + }); + } + if (hasArrayBufferMethod(blobPart)) { return blobPart.arrayBuffer().then(arrayBuffer => { const buf = Buffer.from(arrayBuffer, undefined, blobPart.size); controller.enqueue(buf); diff --git a/packages/node-fetch/src/Body.ts b/packages/node-fetch/src/Body.ts index c50b5fb8629..d2ab7e0e970 100644 --- a/packages/node-fetch/src/Body.ts +++ b/packages/node-fetch/src/Body.ts @@ -1,6 +1,6 @@ import { Readable } from 'stream'; import busboy from 'busboy'; -import { PonyfillBlob } from './Blob.js'; +import { hasArrayBufferMethod, hasBufferMethod, hasBytesMethod, PonyfillBlob } from './Blob.js'; import { PonyfillFile } from './File.js'; import { getStreamFromFormData, PonyfillFormData } from './FormData.js'; import { PonyfillReadableStream } from './ReadableStream.js'; @@ -229,22 +229,29 @@ export class PonyfillBody implements Body { }); } - arrayBuffer(): Promise { + buffer(): Promise { if (this._buffer) { return fakePromise(this._buffer); } if (this.bodyType === BodyInitType.Blob) { - if (this.bodyInit instanceof PonyfillBlob) { + if (hasBufferMethod(this.bodyInit)) { + return this.bodyInit.buffer().then(buf => { + this._buffer = buf; + return this._buffer; + }); + } + if (hasBytesMethod(this.bodyInit)) { + return this.bodyInit.bytes().then(bytes => { + this._buffer = Buffer.from(bytes); + return this._buffer; + }); + } + if (hasArrayBufferMethod(this.bodyInit)) { return this.bodyInit.arrayBuffer().then(buf => { - this._buffer = buf as Buffer; + this._buffer = Buffer.from(buf, undefined, buf.byteLength); return this._buffer; }); } - const bodyInitTyped = this.bodyInit as Blob; - return bodyInitTyped.arrayBuffer().then(arrayBuffer => { - this._buffer = Buffer.from(arrayBuffer, undefined, bodyInitTyped.size); - return this._buffer; - }); } return this._collectChunksFromReadable().then(chunks => { if (chunks.length === 1) { @@ -256,6 +263,14 @@ export class PonyfillBody implements Body { }); } + bytes(): Promise { + return this.buffer(); + } + + arrayBuffer(): Promise { + return this.buffer(); + } + _json: TJSON | null = null; json(): Promise { @@ -263,7 +278,14 @@ export class PonyfillBody implements Body { return fakePromise(this._json); } return this.text().then(text => { - this._json = JSON.parse(text); + try { + this._json = JSON.parse(text); + } catch (e) { + if (e instanceof SyntaxError) { + e.message += `, "${text}" is not valid JSON`; + } + throw e; + } return this._json!; }); } @@ -278,7 +300,7 @@ export class PonyfillBody implements Body { this._text = this.bodyInit as string; return fakePromise(this._text); } - return this.arrayBuffer().then(buffer => { + return this.buffer().then(buffer => { this._text = buffer.toString('utf-8'); return this._text; }); diff --git a/packages/node-fetch/tests/Body.spec.ts b/packages/node-fetch/tests/Body.spec.ts index c2393af18be..2b082883cd9 100644 --- a/packages/node-fetch/tests/Body.spec.ts +++ b/packages/node-fetch/tests/Body.spec.ts @@ -58,7 +58,7 @@ describe('Body', () => { }); it('works with custom decoding', async () => { const body = new PonyfillBody('hello world'); - const buf = await body.arrayBuffer(); + const buf = await body.bytes(); const decoder = new PonyfillTextDecoder('utf-8'); const result = decoder.decode(buf); expect(result).toBe('hello world'); diff --git a/packages/node-fetch/tests/FormData.spec.ts b/packages/node-fetch/tests/FormData.spec.ts index 8815fcd55e0..fc6cd13c56f 100644 --- a/packages/node-fetch/tests/FormData.spec.ts +++ b/packages/node-fetch/tests/FormData.spec.ts @@ -53,7 +53,7 @@ describe('Form Data', () => { const ab = await new PonyfillRequest('http://a', { method: 'post', body: form, - }).arrayBuffer(); + }).bytes(); expect(ab.byteLength >= 15).toBe(true); }); diff --git a/packages/node-fetch/tests/non-http-fetch.spec.ts b/packages/node-fetch/tests/non-http-fetch.spec.ts index 6d85dce2983..ff1d5991450 100644 --- a/packages/node-fetch/tests/non-http-fetch.spec.ts +++ b/packages/node-fetch/tests/non-http-fetch.spec.ts @@ -19,8 +19,8 @@ describe('data uris', () => { expect(res.status).toBe(200); expect(res.headers.get('Content-Type')).toBe(mimeType); expect(res.headers.get('Content-Length')).toBe(length.toString()); - const buf = await res.arrayBuffer(); - expect(buf.toString('base64')).toBe(base64Part); + const buf = await res.bytes(); + expect(Buffer.from(buf).toString('base64')).toBe(base64Part); }); it('should accept data uri with specified charset', async () => { const r = await fetchPonyfill('data:text/plain;charset=UTF-8;page=21,the%20data:1234,5678');