From 4712ae828dbc760440841a0a00b493e5f452916e Mon Sep 17 00:00:00 2001 From: tsctx <91457664+tsctx@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:21:27 +0900 Subject: [PATCH 1/3] feat: implement `BodyReadable.bytes` --- lib/api/readable.js | 42 +++++++++++++++++++++++++++-------- test/client-request.js | 26 ++++++++++++++++++++++ test/types/readable.test-d.ts | 3 +++ types/dispatcher.d.ts | 1 + types/readable.d.ts | 5 +++++ 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/lib/api/readable.js b/lib/api/readable.js index a04ca8edca2..51be2907856 100644 --- a/lib/api/readable.js +++ b/lib/api/readable.js @@ -124,6 +124,11 @@ class BodyReadable extends Readable { return consume(this, 'blob') } + // https://fetch.spec.whatwg.org/#dom-body-bytes + async bytes () { + return consume(this, 'bytes') + } + // https://fetch.spec.whatwg.org/#dom-body-arraybuffer async arrayBuffer () { return consume(this, 'arrayBuffer') @@ -308,6 +313,31 @@ function chunksDecode (chunks, length) { return buffer.utf8Slice(start, bufferLength) } +/** + * @param {Buffer[]} chunks + * @param {number} length + * @returns {Uint8Array} + */ +function chunksConcat (chunks, length) { + if (chunks.length === 0 || length === 0) { + return new Uint8Array(0) + } + if (chunks.length === 1) { + // fast-path + return new Uint8Array(chunks[0]) + } + const buffer = new Uint8Array(length) + + let offset = 0 + for (let i = 0; i < chunks.length; ++i) { + const chunk = chunks[i] + buffer.set(chunk, offset) + offset += chunk.length + } + + return buffer +} + function consumeEnd (consume) { const { type, body, resolve, stream, length } = consume @@ -317,17 +347,11 @@ function consumeEnd (consume) { } else if (type === 'json') { resolve(JSON.parse(chunksDecode(body, length))) } else if (type === 'arrayBuffer') { - const dst = new Uint8Array(length) - - let pos = 0 - for (const buf of body) { - dst.set(buf, pos) - pos += buf.byteLength - } - - resolve(dst.buffer) + resolve(chunksConcat(body, length).buffer) } else if (type === 'blob') { resolve(new Blob(body, { type: stream[kContentType] })) + } else if (type === 'bytes') { + resolve(chunksConcat(body, length)) } consumeFinish(consume) diff --git a/test/client-request.js b/test/client-request.js index 8cbad5ccb48..b015a6408a2 100644 --- a/test/client-request.js +++ b/test/client-request.js @@ -655,6 +655,32 @@ test('request arrayBuffer', async (t) => { await t.completed }) +test('request bytes', async (t) => { + t = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + const bytes = await body.bytes() + + t.deepStrictEqual(Buffer.from(JSON.stringify(obj)), bytes) + t.ok(bytes instanceof Uint8Array) + }) + + await t.completed +}) + test('request body', async (t) => { t = tspl(t, { plan: 1 }) diff --git a/test/types/readable.test-d.ts b/test/types/readable.test-d.ts index d004b706569..b5d32f6c221 100644 --- a/test/types/readable.test-d.ts +++ b/test/types/readable.test-d.ts @@ -20,6 +20,9 @@ expectAssignable(new BodyReadable()) // blob expectAssignable>(readable.blob()) + // bytes + expectAssignable>(readable.bytes()) + // arrayBuffer expectAssignable>(readable.arrayBuffer()) diff --git a/types/dispatcher.d.ts b/types/dispatcher.d.ts index 05f0093c3fd..89350159eab 100644 --- a/types/dispatcher.d.ts +++ b/types/dispatcher.d.ts @@ -244,6 +244,7 @@ declare namespace Dispatcher { readonly bodyUsed: boolean; arrayBuffer(): Promise; blob(): Promise; + bytes(): Promise; formData(): Promise; json(): Promise; text(): Promise; diff --git a/types/readable.d.ts b/types/readable.d.ts index a5fce8a20d3..c4f052af05e 100644 --- a/types/readable.d.ts +++ b/types/readable.d.ts @@ -25,6 +25,11 @@ declare class BodyReadable extends Readable { */ blob(): Promise + /** Consumes and returns the body as an Uint8Array + * https://fetch.spec.whatwg.org/#dom-body-bytes + */ + bytes(): Promise + /** Consumes and returns the body as an ArrayBuffer * https://fetch.spec.whatwg.org/#dom-body-arraybuffer */ From 3b69f1cafc4026b38af2b16f07c3274c4af8456f Mon Sep 17 00:00:00 2001 From: tsctx <91457664+tsctx@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:27:32 +0900 Subject: [PATCH 2/3] fixup --- test/client-request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/client-request.js b/test/client-request.js index b015a6408a2..c67cecdb7f3 100644 --- a/test/client-request.js +++ b/test/client-request.js @@ -674,7 +674,7 @@ test('request bytes', async (t) => { }) const bytes = await body.bytes() - t.deepStrictEqual(Buffer.from(JSON.stringify(obj)), bytes) + t.deepStrictEqual(new TextEncoder().encode(JSON.stringify(obj)), bytes) t.ok(bytes instanceof Uint8Array) }) From 8961385062bc4933811b90b029aa30142d51c58a Mon Sep 17 00:00:00 2001 From: tsctx <91457664+tsctx@users.noreply.github.com> Date: Thu, 4 Jul 2024 18:27:48 +0900 Subject: [PATCH 3/3] update docs, add more test --- README.md | 1 + docs/docs/api/Dispatcher.md | 12 +++++++----- docs/docs/api/Fetch.md | 1 + lib/api/readable.js | 2 +- test/readable.js | 21 +++++++++++++++++++++ 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4336ef06836..2ac58b6695e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The `body` mixins are the most common way to format the request/response body. M - [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) - [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index ecc3cfd61f7..1c153e6a7f2 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -488,11 +488,13 @@ The `RequestOptions.method` property should not be value `'CONNECT'`. `body` contains the following additional [body mixin](https://fetch.spec.whatwg.org/#body-mixin) methods and properties: -- `text()` -- `json()` -- `arrayBuffer()` -- `body` -- `bodyUsed` +* [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) +* [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +* [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) +* [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) +* [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) +* `body` +* `bodyUsed` `body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`. diff --git a/docs/docs/api/Fetch.md b/docs/docs/api/Fetch.md index c3406f128dc..00c349847dc 100644 --- a/docs/docs/api/Fetch.md +++ b/docs/docs/api/Fetch.md @@ -28,6 +28,7 @@ This API is implemented as per the standard, you can find documentation on [MDN] - [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) - [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) - [`.formData()`](https://fetch.spec.whatwg.org/#dom-body-formdata) - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) diff --git a/lib/api/readable.js b/lib/api/readable.js index 51be2907856..2a597c006e7 100644 --- a/lib/api/readable.js +++ b/lib/api/readable.js @@ -326,7 +326,7 @@ function chunksConcat (chunks, length) { // fast-path return new Uint8Array(chunks[0]) } - const buffer = new Uint8Array(length) + const buffer = new Uint8Array(Buffer.allocUnsafeSlow(length).buffer) let offset = 0 for (let i = 0; i < chunks.length; ++i) { diff --git a/test/readable.js b/test/readable.js index dd0631daf8b..e6a6ed0dccd 100644 --- a/test/readable.js +++ b/test/readable.js @@ -83,6 +83,27 @@ describe('Readable', () => { t.deepStrictEqual(arrayBuffer, expected) }) + test('.bytes()', async function (t) { + t = tspl(t, { plan: 1 }) + + function resume () { + } + function abort () { + } + const r = new Readable({ resume, abort }) + + r.push(Buffer.from('hello')) + r.push(Buffer.from(' world')) + + process.nextTick(() => { + r.push(null) + }) + + const bytes = await r.bytes() + + t.deepStrictEqual(bytes, new TextEncoder().encode('hello world')) + }) + test('.json()', async function (t) { t = tspl(t, { plan: 1 })