diff --git a/packages/happy-dom/src/PropertySymbol.ts b/packages/happy-dom/src/PropertySymbol.ts index 74f9963c5..bc9fcf70e 100644 --- a/packages/happy-dom/src/PropertySymbol.ts +++ b/packages/happy-dom/src/PropertySymbol.ts @@ -151,3 +151,4 @@ export const host = Symbol('host'); export const setURL = Symbol('setURL'); export const localName = Symbol('localName'); export const registedClass = Symbol('registedClass'); +export const nodeStream = Symbol('nodeStream'); diff --git a/packages/happy-dom/src/config/IHTMLElementTagNameMap.ts b/packages/happy-dom/src/config/IHTMLElementTagNameMap.ts index 05a48856a..66cadb725 100644 --- a/packages/happy-dom/src/config/IHTMLElementTagNameMap.ts +++ b/packages/happy-dom/src/config/IHTMLElementTagNameMap.ts @@ -23,8 +23,11 @@ import IHTMLVideoElement from '../nodes/html-video-element/IHTMLVideoElement.js' // Makes it work with custom elements when they declare their own interface. declare global { - /* eslint-disable-next-line @typescript-eslint/naming-convention */ + /* eslint-disable @typescript-eslint/naming-convention */ + /* eslint-disable @typescript-eslint/no-empty-interface */ interface HTMLElementTagNameMap {} + /* eslint-enable @typescript-eslint/naming-convention */ + /* eslint-enable @typescript-eslint/no-empty-interface */ } export default interface IHTMLElementTagNameMap extends HTMLElementTagNameMap { diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index a82d54998..e59eab331 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -14,7 +14,6 @@ import { Socket } from 'net'; import Stream from 'stream'; import DataURIParser from './data-uri/DataURIParser.js'; import FetchCORSUtility from './utilities/FetchCORSUtility.js'; -import { ReadableStream } from 'stream/web'; import Request from './Request.js'; import Response from './Response.js'; import Event from '../event/Event.js'; @@ -28,6 +27,7 @@ import FetchResponseRedirectUtility from './utilities/FetchResponseRedirectUtili import FetchResponseHeaderUtility from './utilities/FetchResponseHeaderUtility.js'; import FetchHTTPSCertificate from './certificate/FetchHTTPSCertificate.js'; import { Buffer } from 'buffer'; +import FetchBodyUtility from './utilities/FetchBodyUtility.js'; const LAST_CHUNK = Buffer.from('0\r\n\r\n'); @@ -545,7 +545,10 @@ export default class Fetch { nodeResponse.statusCode === 204 || nodeResponse.statusCode === 304 ) { - this.response = new this.#window.Response(this.nodeToWebStream(body), responseOptions); + this.response = new this.#window.Response( + FetchBodyUtility.nodeToWebStream(body), + responseOptions + ); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -567,7 +570,10 @@ export default class Fetch { // Ignore error as it is forwarded to the response body. } }); - this.response = new this.#window.Response(this.nodeToWebStream(body), responseOptions); + this.response = new this.#window.Response( + FetchBodyUtility.nodeToWebStream(body), + responseOptions + ); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -599,7 +605,10 @@ export default class Fetch { }); } - this.response = new this.#window.Response(this.nodeToWebStream(body), responseOptions); + this.response = new this.#window.Response( + FetchBodyUtility.nodeToWebStream(body), + responseOptions + ); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -607,7 +616,10 @@ export default class Fetch { raw.on('end', () => { // Some old IIS servers return zero-length OK deflate responses, so 'data' is never emitted. if (!this.response) { - this.response = new this.#window.Response(this.nodeToWebStream(body), responseOptions); + this.response = new this.#window.Response( + FetchBodyUtility.nodeToWebStream(body), + responseOptions + ); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -623,7 +635,10 @@ export default class Fetch { // Ignore error as it is forwarded to the response body. } }); - this.response = new this.#window.Response(this.nodeToWebStream(body), responseOptions); + this.response = new this.#window.Response( + FetchBodyUtility.nodeToWebStream(body), + responseOptions + ); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -631,7 +646,10 @@ export default class Fetch { } // Otherwise, use response as is - this.response = new this.#window.Response(this.nodeToWebStream(body), responseOptions); + this.response = new this.#window.Response( + FetchBodyUtility.nodeToWebStream(body), + responseOptions + ); (this.response.redirected) = this.redirectCount > 0; (this.response.url) = this.request.url; this.resolve(this.response); @@ -806,31 +824,4 @@ export default class Fetch { this.reject(error); } } - - /** - * Wraps a Node.js stream into a browser-compatible ReadableStream. - * - * Enables the use of Node.js streams where browser ReadableStreams are required. - * Handles 'data', 'end', and 'error' events from the Node.js stream. - * - * @param nodeStream The Node.js stream to be converted. - * @returns ReadableStream - */ - private nodeToWebStream(nodeStream: Stream): ReadableStream { - return new ReadableStream({ - start(controller) { - nodeStream.on('data', (chunk) => { - controller.enqueue(chunk); - }); - - nodeStream.on('end', () => { - controller.close(); - }); - - nodeStream.on('error', (err) => { - controller.error(err); - }); - } - }); - } } diff --git a/packages/happy-dom/src/fetch/Response.ts b/packages/happy-dom/src/fetch/Response.ts index a85f758da..098387825 100644 --- a/packages/happy-dom/src/fetch/Response.ts +++ b/packages/happy-dom/src/fetch/Response.ts @@ -267,18 +267,15 @@ export default class Response implements IResponse { * @returns Clone. */ public clone(): Response { - const response = new this.#window.Response(this.body, { + const body = FetchBodyUtility.cloneBodyStream(this); + + const response = new this.#window.Response(body, { status: this.status, statusText: this.statusText, headers: this.headers }); - (response.status) = this.status; - (response.statusText) = this.statusText; (response.ok) = this.ok; - (response.headers) = new Headers(this.headers); - (response.body) = this.body; - (response.bodyUsed) = this.bodyUsed; (response.redirected) = this.redirected; (response.type) = this.type; (response.url) = this.url; diff --git a/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts index d203fc3c5..555be0137 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts @@ -9,29 +9,12 @@ import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js'; import IRequestBody from '../types/IRequestBody.js'; import IResponseBody from '../types/IResponseBody.js'; import { Buffer } from 'buffer'; +import Stream from 'stream'; /** * Fetch body utility. */ export default class FetchBodyUtility { - /** - * Wraps a given value in a browser ReadableStream. - * - * This method creates a ReadableStream and immediately enqueues and closes it - * with the provided value, useful for stream API compatibility. - * - * @param value The value to be wrapped in a ReadableStream. - * @returns ReadableStream - */ - public static toReadableStream(value): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.enqueue(value); - controller.close(); - } - }); - } - /** * Parses body and returns stream and type. * @@ -115,8 +98,8 @@ export default class FetchBodyUtility { * It creates a pass through stream and pipes the original stream to it. * * @param requestOrResponse Request or Response. - * @param requestOrResponse.body - * @param requestOrResponse.bodyUsed + * @param requestOrResponse.body Body. + * @param requestOrResponse.bodyUsed Body used. * @returns New stream. */ public static cloneBodyStream(requestOrResponse: { @@ -130,7 +113,25 @@ export default class FetchBodyUtility { ); } + // If a buffer is set, use it to create a new stream. + if (requestOrResponse[PropertySymbol.buffer]) { + return this.toReadableStream(requestOrResponse[PropertySymbol.buffer]); + } + + // Pipe underlying node stream if it exists. + if (requestOrResponse.body[PropertySymbol.nodeStream]) { + const stream1 = new Stream.PassThrough(); + const stream2 = new Stream.PassThrough(); + requestOrResponse.body[PropertySymbol.nodeStream].pipe(stream1); + requestOrResponse.body[PropertySymbol.nodeStream].pipe(stream2); + // Sets the body of the cloned request/response to the first pass through stream. + requestOrResponse.body = this.nodeToWebStream(stream1); + // Returns the clone. + return this.nodeToWebStream(stream2); + } + // Uses the tee() method to clone the ReadableStream + // This requires the stream to be consumed in parallel which is not the case for the fetch API const [stream1, stream2] = requestOrResponse.body.tee(); // Sets the body of the cloned request to the first pass through stream. @@ -198,4 +199,50 @@ export default class FetchBodyUtility { ); } } + /** + * Wraps a given value in a browser ReadableStream. + * + * This method creates a ReadableStream and immediately enqueues and closes it + * with the provided value, useful for stream API compatibility. + * + * @param value The value to be wrapped in a ReadableStream. + * @returns ReadableStream + */ + public static toReadableStream(value): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(value); + controller.close(); + } + }); + } + + /** + * Wraps a Node.js stream into a browser-compatible ReadableStream. + * + * Enables the use of Node.js streams where browser ReadableStreams are required. + * Handles 'data', 'end', and 'error' events from the Node.js stream. + * + * @param nodeStream The Node.js stream to be converted. + * @returns ReadableStream + */ + public static nodeToWebStream(nodeStream: Stream): ReadableStream { + const readableStream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk) => { + controller.enqueue(chunk); + }); + + nodeStream.on('end', () => { + controller.close(); + }); + + nodeStream.on('error', (err) => { + controller.error(err); + }); + } + }); + readableStream[PropertySymbol.nodeStream] = nodeStream; + return readableStream; + } } diff --git a/packages/happy-dom/src/location/Location.ts b/packages/happy-dom/src/location/Location.ts index 76f3eafe2..9bd0fdce5 100644 --- a/packages/happy-dom/src/location/Location.ts +++ b/packages/happy-dom/src/location/Location.ts @@ -8,7 +8,7 @@ import { URL } from 'url'; */ export default class Location { // Public properties - public [Symbol.toStringTag]: string = 'Location'; + public [Symbol.toStringTag] = 'Location'; // Private properties #browserFrame: IBrowserFrame; diff --git a/packages/happy-dom/test/fetch/Response.test.ts b/packages/happy-dom/test/fetch/Response.test.ts index 8234a16e4..12ef46e3a 100644 --- a/packages/happy-dom/test/fetch/Response.test.ts +++ b/packages/happy-dom/test/fetch/Response.test.ts @@ -1,18 +1,19 @@ -import IWindow from '../../src/window/IWindow.js'; -import Window from '../../src/window/Window.js'; -import IDocument from '../../src/nodes/document/IDocument.js'; -import Headers from '../../src/fetch/Headers.js'; -import Blob from '../../src/file/Blob.js'; -import FormData from '../../src/form-data/FormData.js'; -import FetchBodyUtility from '../../src/fetch/utilities/FetchBodyUtility.js'; -import MultipartFormDataParser from '../../src/fetch/multipart/MultipartFormDataParser.js'; import FS from 'fs'; import Path from 'path'; -import File from '../../src/file/File.js'; +import Stream from 'stream'; +import { URLSearchParams } from 'url'; import DOMException from '../../src/exception/DOMException.js'; import DOMExceptionNameEnum from '../../src/exception/DOMExceptionNameEnum.js'; -import { URLSearchParams } from 'url'; -import Stream from 'stream'; +import Headers from '../../src/fetch/Headers.js'; +import MultipartFormDataParser from '../../src/fetch/multipart/MultipartFormDataParser.js'; +import FetchBodyUtility from '../../src/fetch/utilities/FetchBodyUtility.js'; +import Blob from '../../src/file/Blob.js'; +import File from '../../src/file/File.js'; +import FormData from '../../src/form-data/FormData.js'; +import IDocument from '../../src/nodes/document/IDocument.js'; +import IWindow from '../../src/window/IWindow.js'; +import Window from '../../src/window/Window.js'; +import * as PropertySymbol from '../../src/PropertySymbol.js'; import { ReadableStream } from 'stream/web'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; @@ -486,6 +487,83 @@ describe('Response', () => { expect(bodyText).toBe('Hello World'); }); + + it('Can use the body of the cloned Response independently (cached).', async () => { + const originalResponse = new window.Response('Hello World', { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'text/plain' } + }); + const clonedResponse = originalResponse.clone(); + + const originalResponseText = await originalResponse.text(); + const clonedResponseText = await clonedResponse.text(); + expect(originalResponseText).toBe('Hello World'); + expect(clonedResponseText).toBe('Hello World'); + }); + + it('Can use the body of the cloned Response independently (stream).', async () => { + const chunks = ['chunk1', 'chunk2', 'chunk3']; + async function* generate(): AsyncGenerator { + yield await new Promise((resolve) => { + setTimeout(() => { + resolve(Buffer.from(chunks[0])); + }, 10); + }); + yield await new Promise((resolve) => { + setTimeout(() => { + resolve(Buffer.from(chunks[1])); + }, 10); + }); + yield await new Promise((resolve) => { + setTimeout(() => { + resolve(Buffer.from(chunks[2])); + }, 10); + }); + } + const nodeStream = Stream.Readable.from(generate()); + const readableStream = new ReadableStream({ + start(controller) { + controller.enqueue('Hello World'); + controller.close(); + } + }); + // Simulating that there is an underlying node stream + readableStream[PropertySymbol.nodeStream] = nodeStream; + const originalResponse = new window.Response(readableStream, { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'text/plain' } + }); + const clonedResponse = originalResponse.clone(); + + const originalResponseText = await originalResponse.text(); + const clonedResponseText = await clonedResponse.text(); + expect(originalResponseText).toBe('chunk1chunk2chunk3'); + expect(clonedResponseText).toBe('chunk1chunk2chunk3'); + }); + + it('Fails if the body of the original Response is already used.', async () => { + const originalResponse = new window.Response( + new ReadableStream({ + start(controller) { + controller.enqueue('Hello World'); + controller.close(); + } + }), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'text/plain' } + } + ); + await expect(originalResponse.text()).resolves.toBe('Hello World'); + + expect(() => originalResponse.clone()).toThrowError(DOMException); + expect(() => originalResponse.clone()).toThrowError( + 'Failed to clone body stream of request: Request body is already used.' + ); + }); }); describe('static redirect()', () => {