Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#1216@patch: Clone response body when cloning a response. #1219

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/happy-dom/src/PropertySymbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
5 changes: 4 additions & 1 deletion packages/happy-dom/src/config/IHTMLElementTagNameMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 25 additions & 34 deletions packages/happy-dom/src/fetch/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');

Expand Down Expand Up @@ -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
);
(<boolean>this.response.redirected) = this.redirectCount > 0;
(<string>this.response.url) = this.request.url;
this.resolve(this.response);
Expand All @@ -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
);
(<boolean>this.response.redirected) = this.redirectCount > 0;
(<string>this.response.url) = this.request.url;
this.resolve(this.response);
Expand Down Expand Up @@ -599,15 +605,21 @@ export default class Fetch {
});
}

this.response = new this.#window.Response(this.nodeToWebStream(body), responseOptions);
this.response = new this.#window.Response(
FetchBodyUtility.nodeToWebStream(body),
responseOptions
);
(<boolean>this.response.redirected) = this.redirectCount > 0;
(<string>this.response.url) = this.request.url;
this.resolve(this.response);
});
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
);
(<boolean>this.response.redirected) = this.redirectCount > 0;
(<string>this.response.url) = this.request.url;
this.resolve(this.response);
Expand All @@ -623,15 +635,21 @@ 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
);
(<boolean>this.response.redirected) = this.redirectCount > 0;
(<string>this.response.url) = this.request.url;
this.resolve(this.response);
return;
}

// 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
);
(<boolean>this.response.redirected) = this.redirectCount > 0;
(<string>this.response.url) = this.request.url;
this.resolve(this.response);
Expand Down Expand Up @@ -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);
});
}
});
}
}
9 changes: 3 additions & 6 deletions packages/happy-dom/src/fetch/Response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

(<number>response.status) = this.status;
(<string>response.statusText) = this.statusText;
(<boolean>response.ok) = this.ok;
(<Headers>response.headers) = new Headers(this.headers);
(<ReadableStream>response.body) = this.body;
(<boolean>response.bodyUsed) = this.bodyUsed;
(<boolean>response.redirected) = this.redirected;
(<string>response.type) = this.type;
(<string>response.url) = this.url;
Expand Down
87 changes: 67 additions & 20 deletions packages/happy-dom/src/fetch/utilities/FetchBodyUtility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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: {
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion packages/happy-dom/src/location/Location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading