Skip to content

Commit

Permalink
feat(node-fetch): Body.bytes and Blob.bytes
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan committed Jul 20, 2024
1 parent c369de1 commit 145e46e
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 36 deletions.
6 changes: 6 additions & 0 deletions .changeset/large-pumpkins-beg.md
Original file line number Diff line number Diff line change
@@ -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
17 changes: 11 additions & 6 deletions packages/fetchache/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -178,3 +176,10 @@ export interface KeyValueCache<V = any> {
set(key: string, value: V, options?: KeyValueCacheSetOptions): Promise<void>;
delete(key: string): Promise<boolean | void>;
}

function getBytesFromBody(body: Body) {
if ((body as any).bytes) {
return (body as any).bytes();
}
return body.arrayBuffer().then(buffer => new Uint8Array(buffer));
}
126 changes: 111 additions & 15 deletions packages/node-fetch/src/Blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,34 @@ function getBlobPartAsBuffer(blobPart: Exclude<BlobPart, Blob>) {
}
}

function isBlob(obj: any): obj is Blob {
export function hasBufferMethod(obj: any): obj is { buffer(): Promise<Buffer> } {
return obj != null && obj.buffer != null;
}

export function hasArrayBufferMethod(obj: any): obj is { arrayBuffer(): Promise<ArrayBuffer> } {
return obj != null && obj.arrayBuffer != null;
}

export function hasBytesMethod(obj: any): obj is { bytes(): Promise<Uint8Array> } {
return obj != null && obj.bytes != null;
}

export function hasTextMethod(obj: any): obj is { text(): Promise<string> } {
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 {
Expand All @@ -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<Buffer>;
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<void>[] = [];
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);
}
Expand All @@ -80,19 +140,36 @@ export class PonyfillBlob implements Blob {
return fakePromise(Buffer.concat(bufferChunks, this._size || undefined));
}

arrayBuffer(): Promise<ArrayBuffer> {
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() {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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<BlobPart> | undefined;
return new PonyfillReadableStream({
start: controller => {
Expand All @@ -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);
Expand Down
44 changes: 33 additions & 11 deletions packages/node-fetch/src/Body.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -229,22 +229,29 @@ export class PonyfillBody<TJSON = any> implements Body {
});
}

arrayBuffer(): Promise<Buffer> {
buffer(): Promise<Buffer> {
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) {
Expand All @@ -256,14 +263,29 @@ export class PonyfillBody<TJSON = any> implements Body {
});
}

bytes(): Promise<Uint8Array> {
return this.buffer();
}

arrayBuffer(): Promise<ArrayBuffer> {
return this.buffer();
}

_json: TJSON | null = null;

json(): Promise<TJSON> {
if (this._json) {
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!;
});
}
Expand All @@ -278,7 +300,7 @@ export class PonyfillBody<TJSON = any> 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;
});
Expand Down
2 changes: 1 addition & 1 deletion packages/node-fetch/tests/Body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion packages/node-fetch/tests/FormData.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
4 changes: 2 additions & 2 deletions packages/node-fetch/tests/non-http-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down

0 comments on commit 145e46e

Please sign in to comment.