From b91ab5dd8cdc23b3b4f411aed482fb998aba63dd Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 23 Aug 2023 18:08:37 +0200 Subject: [PATCH] feat: automatically enable duplex to stream request body --- package.json | 1 + pnpm-lock.yaml | 13 ++++++---- src/fetch.ts | 59 ++++++++++++++++++++++++++++++---------------- test/index.test.ts | 41 +++++++++++++++++++++++++++++++- 4 files changed, 88 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 44b2f0e..9b52990 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "jiti": "^1.19.3", "listhen": "^1.3.0", "prettier": "^3.0.2", + "std-env": "^3.4.3", "typescript": "^5.1.6", "unbuild": "2.0.0", "vitest": "^0.34.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5414fd6..16b9a91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ devDependencies: prettier: specifier: ^3.0.2 version: 3.0.2 + std-env: + specifier: ^3.4.3 + version: 3.4.3 typescript: specifier: ^5.1.6 version: 5.1.6 @@ -1191,7 +1194,7 @@ packages: istanbul-reports: 3.1.6 magic-string: 0.30.3 picocolors: 1.0.0 - std-env: 3.4.2 + std-env: 3.4.3 test-exclude: 6.0.0 v8-to-istanbul: 9.1.0 vitest: 0.34.2 @@ -1552,7 +1555,7 @@ packages: pkg-types: 1.0.3 scule: 1.0.0 semver: 7.5.4 - std-env: 3.4.2 + std-env: 3.4.3 yaml: 2.3.1 transitivePeerDependencies: - supports-color @@ -3904,8 +3907,8 @@ packages: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true - /std-env@3.4.2: - resolution: {integrity: sha512-Cw6eJDX9AxEEL0g5pYj8Zx9KXtDf60rxwS2ze0HBanS0aKhj1sBlzcsmg+R0qYy8byFa854/yR2X5ZmBSClVmg==} + /std-env@3.4.3: + resolution: {integrity: sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q==} dev: true /string.prototype.trim@1.2.7: @@ -4390,7 +4393,7 @@ packages: magic-string: 0.30.3 pathe: 1.1.1 picocolors: 1.0.0 - std-env: 3.4.2 + std-env: 3.4.3 strip-literal: 1.3.0 tinybench: 2.5.0 tinypool: 0.7.0 diff --git a/src/fetch.ts b/src/fetch.ts index ec8dc50..7273329 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,3 +1,4 @@ +import type { Readable } from "node:stream"; import destr from "destr"; import { withBase, withQuery } from "ufo"; import type { Fetch, RequestInfo, RequestInit, Response } from "./types"; @@ -45,6 +46,13 @@ export interface FetchOptions parseResponse?: (responseText: string) => any; responseType?: R; + /** + * @experimental Set to "half" to enable duplex streaming. + * Will be automatically set to "half" when using a ReadableStream as body. + * https://fetch.spec.whatwg.org/#enumdef-requestduplex + */ + duplex?: "half" | undefined; + /** timeout in milliseconds */ timeout?: number; @@ -182,26 +190,37 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { ...context.options.query, }); } - if ( - context.options.body && - isPayloadMethod(context.options.method) && - isJSONSerializable(context.options.body) - ) { - // Automatically JSON stringify request bodies, when not already a string. - context.options.body = - typeof context.options.body === "string" - ? context.options.body - : JSON.stringify(context.options.body); - - // Set Content-Type and Accept headers to application/json by default - // for JSON serializable request bodies. - // Pass empty object as older browsers don't support undefined. - context.options.headers = new Headers(context.options.headers || {}); - if (!context.options.headers.has("content-type")) { - context.options.headers.set("content-type", "application/json"); - } - if (!context.options.headers.has("accept")) { - context.options.headers.set("accept", "application/json"); + if (context.options.body && isPayloadMethod(context.options.method)) { + if (isJSONSerializable(context.options.body)) { + // JSON Body + // Automatically JSON stringify request bodies, when not already a string. + context.options.body = + typeof context.options.body === "string" + ? context.options.body + : JSON.stringify(context.options.body); + + // Set Content-Type and Accept headers to application/json by default + // for JSON serializable request bodies. + // Pass empty object as older browsers don't support undefined. + context.options.headers = new Headers(context.options.headers || {}); + if (!context.options.headers.has("content-type")) { + context.options.headers.set("content-type", "application/json"); + } + if (!context.options.headers.has("accept")) { + context.options.headers.set("accept", "application/json"); + } + } else if ( + // ReadableStream Body + ("pipeTo" in (context.options.body as ReadableStream) && + typeof (context.options.body as ReadableStream).pipeTo === + "function") || + // Node.js Stream Body + typeof (context.options.body as Readable).pipe === "function" + ) { + // eslint-disable-next-line unicorn/no-lonely-if + if (!("duplex" in context.options)) { + context.options.duplex = "half"; + } } } } diff --git a/test/index.test.ts b/test/index.test.ts index fa1d5ac..8558ab3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,3 +1,4 @@ +import { Readable } from "node:stream"; import { listen } from "listhen"; import { getQuery, joinURL } from "ufo"; import { @@ -10,6 +11,7 @@ import { } from "h3"; import { describe, beforeAll, afterAll, it, expect } from "vitest"; import { Headers, FormData, Blob } from "node-fetch-native"; +import { nodeMajorVersion } from "std-env"; import { $fetch } from "../src/node"; describe("ofetch", () => { @@ -170,7 +172,7 @@ describe("ofetch", () => { expect(body).to.deep.eq(message); }); - it("Handle buffer body", async () => { + it("Handle Buffer body", async () => { const message = "Hallo von Pascal"; const { body } = await $fetch(getURL("echo"), { method: "POST", @@ -180,6 +182,43 @@ describe("ofetch", () => { expect(body).to.deep.eq(message); }); + it.skipIf(Number(nodeMajorVersion) < 18)( + "Handle ReadableStream body", + async () => { + const message = "Hallo von Pascal"; + const { body } = await $fetch(getURL("echo"), { + method: "POST", + headers: { + "content-length": "16", + }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(message)); + controller.close(); + }, + }), + }); + expect(body).to.deep.eq(message); + } + ); + + it.skipIf(Number(nodeMajorVersion) < 18)("Handle Readable body", async () => { + const message = "Hallo von Pascal"; + const { body } = await $fetch(getURL("echo"), { + method: "POST", + headers: { + "content-length": "16", + }, + body: new Readable({ + read() { + this.push(message); + this.push(null); // eslint-disable-line unicorn/no-null + }, + }), + }); + expect(body).to.deep.eq(message); + }); + it("Bypass FormData body", async () => { const data = new FormData(); data.append("foo", "bar");