Skip to content

Commit

Permalink
chore: [capricorn86#1216] Adds support for piping underlying Node str…
Browse files Browse the repository at this point in the history
…eam when cloning a Response body
  • Loading branch information
capricorn86 committed Mar 9, 2024
1 parent 741a991 commit 032bdc4
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 60 deletions.
1 change: 1 addition & 0 deletions packages/happy-dom/src/PropertySymbol.ts
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');
65 changes: 30 additions & 35 deletions packages/happy-dom/src/fetch/Fetch.ts
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 @@ -123,7 +123,11 @@ export default class Fetch {
this.#window.location.protocol === 'https:'
) {
throw new DOMException(
`Mixed Content: The page at '${this.#window.location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${this.request.url}'. This request has been blocked; the content must be served over HTTPS.`,
`Mixed Content: The page at '${
this.#window.location.href
}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${
this.request.url
}'. This request has been blocked; the content must be served over HTTPS.`,
DOMExceptionNameEnum.securityError
);
}
Expand Down Expand Up @@ -545,7 +549,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 +574,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 +609,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 +639,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 +828,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);
});
}
});
}
}
5 changes: 0 additions & 5 deletions packages/happy-dom/src/fetch/Response.ts
Expand Up @@ -275,12 +275,7 @@ export default class Response implements IResponse {
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
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;
}
}

0 comments on commit 032bdc4

Please sign in to comment.