diff --git a/.changeset/five-donkeys-behave.md b/.changeset/five-donkeys-behave.md new file mode 100644 index 0000000000..40f35537bb --- /dev/null +++ b/.changeset/five-donkeys-behave.md @@ -0,0 +1,6 @@ +--- +'@whatwg-node/fetch': patch +'@whatwg-node/server': patch +--- + +Do not patch global Headers if it is native, and support URL as a first parameter of `fetch` diff --git a/packages/fetch/dist/create-node-ponyfill.js b/packages/fetch/dist/create-node-ponyfill.js index 9d3b47faa1..a62bf1e4ec 100644 --- a/packages/fetch/dist/create-node-ponyfill.js +++ b/packages/fetch/dist/create-node-ponyfill.js @@ -11,7 +11,7 @@ module.exports = function createNodePonyfill(opts = {}) { const ponyfills = {}; if (!opts.useNodeFetch) { - ponyfills.fetch = globalThis.fetch; // To enable: import {fetch} from 'cross-fetch' + ponyfills.fetch = globalThis.fetch; ponyfills.Headers = globalThis.Headers; ponyfills.Request = globalThis.Request; ponyfills.Response = globalThis.Response; @@ -121,8 +121,7 @@ module.exports = function createNodePonyfill(opts = {}) { class Request extends OriginalRequest { constructor(requestOrUrl, options) { - if (typeof requestOrUrl === "string") { - options = options || {}; + if (typeof requestOrUrl === "string" || requestOrUrl instanceof URL) { super(requestOrUrl, options); const contentType = this.headers.get("content-type"); if (contentType && contentType.startsWith("multipart/form-data")) { @@ -140,7 +139,7 @@ module.exports = function createNodePonyfill(opts = {}) { const originalFetch = ponyfills.fetch || undici.fetch; const fetch = function (requestOrUrl, options) { - if (typeof requestOrUrl === "string") { + if (typeof requestOrUrl === "string" || requestOrUrl instanceof URL) { // We cannot use our ctor because it leaks on Node 18's global fetch return originalFetch(requestOrUrl, options); } @@ -169,7 +168,7 @@ module.exports = function createNodePonyfill(opts = {}) { if (!ponyfills.Headers) { ponyfills.Headers = nodeFetch.Headers; // Sveltekit - if (globalThis.Headers) { + if (globalThis.Headers && nodeMajor < 18) { Object.defineProperty(globalThis.Headers, Symbol.hasInstance, { value(obj) { return obj && obj.get && obj.set && obj.delete && obj.has && obj.append; @@ -192,28 +191,29 @@ module.exports = function createNodePonyfill(opts = {}) { class Request extends OriginalRequest { constructor(requestOrUrl, options) { - if (typeof requestOrUrl === "string") { + if (typeof requestOrUrl === "string" || requestOrUrl instanceof URL) { // Support schemaless URIs on the server for parity with the browser. // Ex: //github.com/ -> https://github.com/ - if (/^\/\//.test(requestOrUrl)) { - requestOrUrl = "https:" + requestOrUrl; + if (/^\/\//.test(requestOrUrl.toString())) { + requestOrUrl = "https:" + requestOrUrl.toString(); } - options = options || {}; - options.headers = new ponyfills.Headers(options.headers || {}); - options.headers.set('Connection', 'keep-alive'); - if (options.body != null) { - if (options.body[Symbol.toStringTag] === 'FormData') { - const encoder = new formDataEncoderModule.FormDataEncoder(options.body) + const fixedOptions = { + ...options + }; + fixedOptions.headers = new ponyfills.Headers(fixedOptions.headers || {}); + fixedOptions.headers.set('Connection', 'keep-alive'); + if (fixedOptions.body != null) { + if (fixedOptions.body[Symbol.toStringTag] === 'FormData') { + const encoder = new formDataEncoderModule.FormDataEncoder(fixedOptions.body) for (const headerKey in encoder.headers) { - options.headers.set(headerKey, encoder.headers[headerKey]) + fixedOptions.headers.set(headerKey, encoder.headers[headerKey]) } - options.body = streams.Readable.from(encoder.encode()); - } - if (options.body[Symbol.toStringTag] === 'ReadableStream') { - options.body = readableStreamToReadable(options.body); + fixedOptions.body = streams.Readable.from(encoder); + } else if (fixedOptions.body[Symbol.toStringTag] === 'ReadableStream') { + fixedOptions.body = readableStreamToReadable(fixedOptions.body); } } - super(requestOrUrl, options); + super(requestOrUrl, fixedOptions); } else { super(requestOrUrl); } @@ -222,7 +222,7 @@ module.exports = function createNodePonyfill(opts = {}) { } ponyfills.Request = Request; const fetch = function (requestOrUrl, options) { - if (typeof requestOrUrl === "string") { + if (typeof requestOrUrl === "string" || requestOrUrl instanceof URL) { return fetch(new Request(requestOrUrl, options)); } if (requestOrUrl.url.startsWith('file:')) { diff --git a/packages/fetch/dist/readableStreamToReadable.js b/packages/fetch/dist/readableStreamToReadable.js index 90c5bcf256..cba6fb1629 100644 --- a/packages/fetch/dist/readableStreamToReadable.js +++ b/packages/fetch/dist/readableStreamToReadable.js @@ -1,19 +1,20 @@ const streams = require('stream'); module.exports = function readableStreamToReadable(readableStream) { - return streams.Readable.from({ - [Symbol.asyncIterator]() { - const reader = readableStream.getReader(); - return { - next() { - return reader.read(); - }, - async return() { - reader.releaseLock(); - await readableStream.cancel(); - return Promise.resolve({ done: true }); - } - } - } - }); + return streams.Readable.from({ + [Symbol.asyncIterator]() { + const reader = readableStream.getReader(); + return { + next() { + return reader.read(); + }, + async return() { + reader.cancel(); + reader.releaseLock(); + await readableStream.cancel(); + return { done: true }; + } + } + } + }); } \ No newline at end of file diff --git a/packages/fetch/tests/getFormDataMethod.spec.ts b/packages/fetch/tests/getFormDataMethod.spec.ts index 2e291aec5c..d1116a159c 100644 --- a/packages/fetch/tests/getFormDataMethod.spec.ts +++ b/packages/fetch/tests/getFormDataMethod.spec.ts @@ -1,43 +1,47 @@ -import { createFetch } from '@whatwg-node/fetch'; +import { createTestContainer } from '../../server/test/create-test-container'; describe('getFormDataMethod', () => { ['fieldsFirst:true', 'fieldsFirst:false'].forEach(fieldsFirstFlag => { - const fetchAPI = createFetch({ - formDataLimits: { - fieldsFirst: fieldsFirstFlag === 'fieldsFirst:true', - }, - }); describe(fieldsFirstFlag, () => { - it('should parse fields correctly', async () => { - const formData = new fetchAPI.FormData(); - formData.append('greetings', 'Hello world!'); - formData.append('bye', 'Goodbye world!'); - const request = new fetchAPI.Request('http://localhost:8080', { - method: 'POST', - body: formData, - }); - const formdata = await request.formData(); - expect(formdata.get('greetings')).toBe('Hello world!'); - expect(formdata.get('bye')).toBe('Goodbye world!'); - }); - it('should parse and receive text files correctly', async () => { - const formData = new fetchAPI.FormData(); - const greetingsFile = new fetchAPI.File(['Hello world!'], 'greetings.txt', { type: 'text/plain' }); - const byeFile = new fetchAPI.File(['Goodbye world!'], 'bye.txt', { type: 'text/plain' }); - formData.append('greetings', greetingsFile); - formData.append('bye', byeFile); - const request = new fetchAPI.Request('http://localhost:8080', { - method: 'POST', - body: formData, - }); - const formdata = await request.formData(); - const receivedGreetingsFile = formdata.get('greetings') as File; - const receivedGreetingsText = await receivedGreetingsFile.text(); - expect(receivedGreetingsText).toBe('Hello world!'); - const receivedByeFile = formdata.get('bye') as File; - const receivedByeText = await receivedByeFile.text(); - expect(receivedByeText).toBe('Goodbye world!'); - }); + createTestContainer( + fetchAPI => { + it('should parse fields correctly', async () => { + const formData = new fetchAPI.FormData(); + formData.append('greetings', 'Hello world!'); + formData.append('bye', 'Goodbye world!'); + const request = new fetchAPI.Request('http://localhost:8080', { + method: 'POST', + body: formData, + }); + const formdata = await request.formData(); + expect(formdata.get('greetings')).toBe('Hello world!'); + expect(formdata.get('bye')).toBe('Goodbye world!'); + }); + it('should parse and receive text files correctly', async () => { + const formData = new fetchAPI.FormData(); + const greetingsFile = new fetchAPI.File(['Hello world!'], 'greetings.txt', { type: 'text/plain' }); + const byeFile = new fetchAPI.File(['Goodbye world!'], 'bye.txt', { type: 'text/plain' }); + formData.append('greetings', greetingsFile); + formData.append('bye', byeFile); + const request = new fetchAPI.Request('http://localhost:8080', { + method: 'POST', + body: formData, + }); + const formdata = await request.formData(); + const receivedGreetingsFile = formdata.get('greetings') as File; + const receivedGreetingsText = await receivedGreetingsFile.text(); + expect(receivedGreetingsText).toBe('Hello world!'); + const receivedByeFile = formdata.get('bye') as File; + const receivedByeText = await receivedByeFile.text(); + expect(receivedByeText).toBe('Goodbye world!'); + }); + }, + { + formDataLimits: { + fieldsFirst: fieldsFirstFlag === 'fieldsFirst:true', + }, + } + ); }); }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 27c700414e..c2c7a3c4ac 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -185,9 +185,9 @@ function createServerAdapter< if (typeof input === 'string' || input instanceof URL) { const [initOrCtx, ...restOfCtx] = maybeCtx; if (isRequestInit(initOrCtx)) { - return handleRequestWithWaitUntil(new RequestCtor(input.toString(), initOrCtx), ...restOfCtx); + return handleRequestWithWaitUntil(new RequestCtor(input, initOrCtx), ...restOfCtx); } - return handleRequestWithWaitUntil(new RequestCtor(input.toString()), ...maybeCtx); + return handleRequestWithWaitUntil(new RequestCtor(input), ...maybeCtx); } return handleRequestWithWaitUntil(input, ...maybeCtx); }; @@ -206,7 +206,7 @@ function createServerAdapter< } if (isServerResponse(initOrCtxOrRes)) { - throw new Error('Got Node response without Node request'); + throw new TypeError('Got Node response without Node request'); } // Is input a container object over Request? diff --git a/packages/server/src/utils.ts b/packages/server/src/utils.ts index 4c436a84f5..5cc249778f 100644 --- a/packages/server/src/utils.ts +++ b/packages/server/src/utils.ts @@ -177,8 +177,8 @@ export async function sendNodeResponse( serverResponse.end(body, resolve); } else if (isReadable(body)) { serverResponse.once('close', () => { - resolve(); body.destroy(); + resolve(); }); body.pipe(serverResponse); } else if (isAsyncIterable(body)) { diff --git a/packages/server/test/create-test-container.ts b/packages/server/test/create-test-container.ts index 21f89e3e11..c77d3201b1 100644 --- a/packages/server/test/create-test-container.ts +++ b/packages/server/test/create-test-container.ts @@ -4,7 +4,7 @@ export function createTestContainer( fn: (fetchAPI: ReturnType) => void, extraFlags: Parameters[0] = {} ) { - ['default-fetch' /*, 'node-fetch' */].forEach(fetchImplementation => { + ['default-fetch', 'node-fetch'].forEach(fetchImplementation => { describe(fetchImplementation, () => { const fetchAPI = createFetch({ useNodeFetch: fetchImplementation === 'node-fetch', diff --git a/packages/server/test/node.spec.ts b/packages/server/test/node.spec.ts index 272a0bfc9e..5a9b1f488d 100644 --- a/packages/server/test/node.spec.ts +++ b/packages/server/test/node.spec.ts @@ -1,7 +1,7 @@ import { createServerAdapter } from '@whatwg-node/server'; -import { fetch, Response, ReadableStream } from '@whatwg-node/fetch'; import { IncomingMessage, ServerResponse } from 'http'; import { createTestServer, TestServer } from './test-server'; +import { createTestContainer } from './create-test-container'; describe('Node Specific Cases', () => { let testServer: TestServer; @@ -13,87 +13,90 @@ describe('Node Specific Cases', () => { testServer.server.close(done); }); - it('should handle empty responses', async () => { - const serverAdapter = createServerAdapter(() => { - return undefined as any; + createTestContainer(({ Request, Response, ReadableStream, fetch }) => { + it('should handle empty responses', async () => { + const serverAdapter = createServerAdapter(() => { + return undefined as any; + }, Request); + testServer.server.once('request', serverAdapter); + const response = await fetch(testServer.url); + await response.text(); + expect(response.status).toBe(404); }); - testServer.server.once('request', serverAdapter); - const response = await fetch(testServer.url); - await response.text(); - expect(response.status).toBe(404); - }); - it('should handle waitUntil properly', async () => { - let flag = false; - const serverAdapter = createServerAdapter((_request, { waitUntil }) => { - waitUntil( - sleep(100).then(() => { - flag = true; - }) - ); - return new Response(null, { - status: 204, - }); + it('should handle waitUntil properly', async () => { + let flag = false; + const serverAdapter = createServerAdapter((_request, { waitUntil }) => { + waitUntil( + sleep(100).then(() => { + flag = true; + }) + ); + return new Response(null, { + status: 204, + }); + }, Request); + testServer.server.once('request', serverAdapter); + const response$ = fetch(testServer.url); + const response = await response$; + await response.text(); + expect(flag).toBe(false); + await sleep(100); + expect(flag).toBe(true); }); - testServer.server.once('request', serverAdapter); - const response$ = fetch(testServer.url); - const response = await response$; - await response.text(); - expect(flag).toBe(false); - await sleep(100); - expect(flag).toBe(true); - }); - it('should forward additional context', async () => { - const handleRequest = jest.fn().mockImplementation(() => { - return new Response(null, { - status: 204, + it('should forward additional context', async () => { + const handleRequest = jest.fn().mockImplementation(() => { + return new Response(null, { + status: 204, + }); }); + const serverAdapter = createServerAdapter<{ + req: IncomingMessage; + res: ServerResponse; + foo: string; + }>(handleRequest, Request); + const additionalCtx = { foo: 'bar' }; + testServer.server.once('request', (...args) => serverAdapter(...args, additionalCtx)); + const response = await fetch(testServer.url); + await response.text(); + expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.objectContaining(additionalCtx)); }); - const serverAdapter = createServerAdapter<{ - req: IncomingMessage; - res: ServerResponse; - foo: string; - }>(handleRequest); - const additionalCtx = { foo: 'bar' }; - testServer.server.once('request', (...args) => serverAdapter(...args, additionalCtx)); - const response = await fetch(testServer.url); - await response.text(); - expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.objectContaining(additionalCtx)); - }); - it('should handle cancellation of incremental responses', async () => { - const cancelFn = jest.fn(); - const serverAdapter = createServerAdapter( - () => - new Response( - new ReadableStream({ - async pull(controller) { - await sleep(100); - controller.enqueue(Date.now().toString()); - }, - cancel: cancelFn, - }) - ) - ); + it('should handle cancellation of incremental responses', async () => { + const cancelFn = jest.fn(); + const serverAdapter = createServerAdapter( + () => + new Response( + new ReadableStream({ + async pull(controller) { + await sleep(100); + controller.enqueue(Date.now().toString()); + }, + cancel: cancelFn, + }) + ), + Request + ); - testServer.server.once('request', serverAdapter); - const response = await fetch(testServer.url); + testServer.server.once('request', serverAdapter); + const response = await fetch(testServer.url); - const collectedValues: string[] = []; + const collectedValues: string[] = []; - let i = 0; - for await (const chunk of response.body as any as AsyncIterable) { - if (i > 2) { - break; + let i = 0; + for await (const chunk of response.body as any as AsyncIterable) { + if (i > 2) { + break; + } + collectedValues.push(Buffer.from(chunk).toString('utf-8')); + i++; } - collectedValues.push(Buffer.from(chunk).toString('utf-8')); - i++; - } - expect(collectedValues).toHaveLength(3); - await sleep(100); - expect(cancelFn).toHaveBeenCalledTimes(1); + expect(collectedValues).toHaveLength(3); + await sleep(100); + expect(cancelFn).toHaveBeenCalledTimes(1); + }); }); });