-
-
Notifications
You must be signed in to change notification settings - Fork 213
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
feat: [#1502] Allow fetch to be intercepted and modified #1662
feat: [#1502] Allow fetch to be intercepted and modified #1662
Conversation
e95910f
to
a9ec9d8
Compare
I think your approach is good @OlaviSau 🙂 I agree that there are many approaches with their pros and cons and it is not easy to say which one is the best. After thinking about it, my suggestion is: IFetchInterceptor.ts export default interface IFetchInterceptor {
beforeRequest?: (window: BrowserWindow, request: Request) => Promise<Response | void>;
beforeSyncRequest?: (window: BrowserWindow, request: Request) => ISyncResponse | void;
afterResponse? (window: BrowserWindow, request: Request, response: Response) => Promise<Response | void>;
afterSyncResponse? (window: BrowserWindow, request: Request, response: ISyncResponse) => Promise<ISyncResponse | void>;
} IBrowserSettings.ts export default interface IBrowserSettings {
fetch: {
interceptor?: IFetchInterceptor
};
} I'm not sure "beforeRequest" is better than "beforeSend", but looking at some other libs it seems like it is common to use "beforeRequest" and "afterResponse". I think it will make the code easier to understand and maintain if all interceptor logic is within the IFetchInterceptor interface (both for async and sync). I'm thinking that async is the default fetch behavior, so it might not need to be prefixed with "Async". |
Thanks for the response ❤️ In terms of remove the the asyncFetch - I think that also makes sense.
It seems that it varies quite a bit what the user needs, perhaps I should use a context object instead? I thought a bit about the extra layer and now I actually think it's fine that they are all in the same interceptor as the user can use composition on their class if they want to split functionality / handle data persistence in a shared manner. That allows me to remove one layer from the settings so I am happy about that :) |
0184a4f
to
ea4f90f
Compare
ea4f90f
to
050bab7
Compare
I added sync request for now as well. Will add response hooks soon. |
I'm glad to hear that you agree for the most part with my suggestion 😄 I get your point. I know that many libraries would send the parameters in that order. I see it as the injection pattern where the context is the most important parameter that always has to be sent no matter which function, and request is the second to know which request it is about. This way we know that the first parameter will always be the same, instead of not knowing where the window parameter is. Example: This is how most logic works for other Happy DOM functionality as much logic needs to know which Window context it is part of, specially when dealing with multiple Window instances running at the same time. The consumer will most likely need to know which Window at some point. It could for example be for checking the origin of the request as it can potentially be an iframe or another page. Another way as I believe you suggested is to send in some kind of object containing the properties. Objects are nice for the consumer, except if they require custom typings, but perhaps it isn't that big of a problem. I hope I explained my thinking a little bit 😅 |
I see, yes, that's a good point, then the window would be at a predictable location. I think in that case in order to have consistency, but also contend with only needing certain parameters at different times the object seems to be a good solution. One note - I exposed ISyncResponse so that the user would be able to type their responses correctly. |
@@ -39,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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this cause any issues? @capricorn86
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is only used internally, so it should be fine
b606383
to
c5f8b26
Compare
@capricorn86 I have added the response methods as well. I am noticing that there is quite a bit of duplication in the tests for the Fetch / SyncFetch now. Should I try to eliminate some of it or is it okay? |
One other thing I was thinking about was perhaps splitting the afterResponse into two - beforeCache and afterCache. The naming would be tricky though so I just decided to leave it with the current plan. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good! 🙂 Just a small finding
@@ -383,7 +402,14 @@ export default class Fetch { | |||
}); | |||
} | |||
this.#browserFrame[PropertySymbol.asyncTaskManager].endTask(taskID); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to end the task for the "asyncTaskManager" after the intercepted response has been handled
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed it :)
@@ -39,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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is only used internally, so it should be fine
As long as the code is still easy to read and understand after removing any duplication you can try to eliminate some of it. To me it is better with some duplication and more code if it makes it easier to understand it. |
I thought about it a bit and I could replace the mockModule part with a function that would return a promise that then returns the requestArgs. If possible I would like to do this in a secondary PR for easier review. This is what I had in mind.
The consumption would look something like
I tested it with some tests, seems that the GH workflow doesn't always run all tests, so I caught a bug I added at some point by using the interceptor without optional access. |
…om:OlaviSau/happy-dom into feature-1502-allow-fetch-to-be-intercepted
Sounds good! It could probably clean it up a bit for a majority of the tests 🙂 It might not be possible to use it for some of the tests (like tests for post or chunks). |
A proposal for implementation.
The intercept option is structured a bit deep, but it is for a reason. Since there exists a SyncFetch as well I could not come up with a good name that would clearly imply that the interceptor only applies for async requests and not for sync requests.
I considered the following options:
{ fetch: { beforeSend: ... } }
This is confusing as there is SyncFetch and Fetch. The types wouldn't match either for the response object. In addition if the options were to be a class, the method would be lost during setup in quite a confusing way.
{ fetch: { beforeAsync: ... } }
This doesn't clarify before what and has the issue of losing the methods.
{ fetch: { asyncInterceptor: { beforeSend: ... } } }
The use might assume that the interceptor is allowed to be async and applies both to SyncFetch and Fetch. Even if we change it to asyncFetchInterceptor it retains that problem.
Methods can be lost during spreading in BrowserSettingsFactory.createSettings(options?.settings) as methods from classes are not enumerable by default.
I also considered whether we could use the interceptor for setting the options instead of using the declarative way - it would allow for dynamically setting some options on fetch like disableCache, redirectCount or disableSameOriginPolicy. The downside is that it would require exposing the Fetch type to some extent. I'll leave that decision to @capricorn86.
The window is exposed due to the need for window for creating the response object as the response object cannot be created independently of the window. That would cause problems when creating the interceptor before the Window, which should be the default use case. It might also be useful if the interceptor is used for multiple windows.
If this approach is suitable - I can add a hooks for afterResponse, afterError and other cases that the users would like.