Skip to content

Commit

Permalink
feat: [1502] Allow fetch response to be intercepted
Browse files Browse the repository at this point in the history
  • Loading branch information
OlaviSau committed Jan 6, 2025
1 parent fdda4ed commit c5f8b26
Show file tree
Hide file tree
Showing 4 changed files with 335 additions and 7 deletions.
24 changes: 19 additions & 5 deletions packages/happy-dom/src/fetch/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const LAST_CHUNK = Buffer.from('0\r\n\r\n');
*/
export default class Fetch {
private reject: (reason: Error) => void | null = null;
private resolve: (value: Response | Promise<Response>) => void | null = null;
private resolve: (value: Response | Promise<Response>) => Promise<void> = null;
private listeners = {
onSignalAbort: this.onSignalAbort.bind(this)
};
Expand Down Expand Up @@ -134,7 +134,14 @@ export default class Fetch {
this.response = new this.#window.Response(result.buffer, {
headers: { 'Content-Type': result.type }
});
return this.response;
const interceptedResponse = this.interceptor.afterAsyncResponse
? await this.interceptor.afterAsyncResponse({
window: this.#window,
response: this.response,
request: this.request
})
: undefined;
return interceptedResponse instanceof Response ? interceptedResponse : this.response;
}

// Security check for "https" to "http" requests.
Expand Down Expand Up @@ -377,9 +384,9 @@ export default class Fetch {
throw new this.#window.Error('Fetch already sent.');
}

this.resolve = (response: Response | Promise<Response>): void => {
this.resolve = async (response: Response | Promise<Response>): Promise<void> => {
// We can end up here when closing down the browser frame and there is an ongoing request.
// Therefore we need to check if browserFrame.page.context is still available.
// Therefore, we need to check if browserFrame.page.context is still available.
if (
!this.disableCache &&
response instanceof Response &&
Expand All @@ -395,7 +402,14 @@ export default class Fetch {
});
}
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
resolve(response);
const interceptedResponse = this.interceptor.afterAsyncResponse
? await this.interceptor.afterAsyncResponse({
window: this.#window,
response: await response,
request: this.request
})
: undefined;
resolve(interceptedResponse instanceof Response ? interceptedResponse : response);
};
this.reject = (error: Error): void => {
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID);
Expand Down
19 changes: 17 additions & 2 deletions packages/happy-dom/src/fetch/SyncFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export default class SyncFetch {

if (this.request[PropertySymbol.url].protocol === 'data:') {
const result = DataURIParser.parse(this.request.url);
return {
const response = {
status: 200,
statusText: 'OK',
ok: true,
Expand All @@ -125,6 +125,14 @@ export default class SyncFetch {
headers: new Headers({ 'Content-Type': result.type }),
body: result.buffer
};
const interceptedResponse = this.interceptor.afterSyncResponse
? this.interceptor.afterSyncResponse({
window: this.#window,
response,
request: this.request
})
: undefined;
return typeof interceptedResponse === 'object' ? interceptedResponse : response;
}

// Security check for "https" to "http" requests.
Expand Down Expand Up @@ -428,7 +436,14 @@ export default class SyncFetch {
});
}

return redirectedResponse;
const interceptedResponse = this.interceptor.afterSyncResponse
? this.interceptor.afterSyncResponse({
window: this.#window,
response: redirectedResponse,
request: this.request
})
: undefined;
return typeof interceptedResponse === 'object' ? interceptedResponse : redirectedResponse;
}

/**
Expand Down
106 changes: 106 additions & 0 deletions packages/happy-dom/test/fetch/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,112 @@ describe('Fetch', () => {
expect(await response.text()).toBe('some text');
});

it('Should use intercepted response when given', async () => {
const originURL = 'https://localhost:8080/';
const responseText = 'some text';
const window = new Window({
url: originURL,
settings: {
fetch: {
interceptor: {
async afterAsyncResponse({ window }) {
return new window.Response('intercepted text');
}
}
}
}
});
const url = 'https://localhost:8080/some/path';

mockModule('https', {
request: () => {
return {
end: () => {},
on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => {
if (event === 'response') {
async function* generate(): AsyncGenerator<string> {
yield responseText;
}

const response = <HTTP.IncomingMessage>Stream.Readable.from(generate());

response.statusCode = 200;
response.statusMessage = 'OK';
response.headers = {};
response.rawHeaders = [
'content-type',
'text/html',
'content-length',
String(responseText.length)
];

callback(response);
}
},
setTimeout: () => {}
};
}
});

const response = await window.fetch(url);

expect(await response.text()).toBe('intercepted text');
});

it('Should use original response when no response is given', async () => {
const originURL = 'https://localhost:8080/';
const responseText = 'some text';
const window = new Window({
url: originURL,
settings: {
fetch: {
interceptor: {
async afterAsyncResponse({ response }) {
response.headers.set('x-test', 'yes');
return undefined;
}
}
}
}
});
const url = 'https://localhost:8080/some/path';

mockModule('https', {
request: () => {
return {
end: () => {},
on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => {
if (event === 'response') {
async function* generate(): AsyncGenerator<string> {
yield responseText;
}

const response = <HTTP.IncomingMessage>Stream.Readable.from(generate());

response.statusCode = 200;
response.statusMessage = 'OK';
response.headers = {};
response.rawHeaders = [
'content-type',
'text/html',
'content-length',
String(responseText.length)
];

callback(response);
}
},
setTimeout: () => {}
};
}
});

const response = await window.fetch(url);

expect(await response.text()).toBe(responseText);
expect(response.headers.get('x-test')).toBe('yes');
});

it('Forwards "cookie", "authorization" or "www-authenticate" if request credentials are set to "same-origin" and the request goes to the same origin as the document.', async () => {
const originURL = 'https://localhost:8080';
const window = new Window({ url: originURL });
Expand Down
Loading

0 comments on commit c5f8b26

Please sign in to comment.