angular icon indicating copy to clipboard operation
angular copied to clipboard

Allow caching HTTP requests when urls differ between server and client

Open jsaguet opened this issue 1 year ago • 6 comments

Which @angular/* package(s) are relevant/related to the feature request?

common, platform-browser

Description

Following #50117, additional configuration was added to the HTTP transfer cache. One use-case was mentioned in several replies and other issues about the lack of customization of the cache key but it was not handled in the associated PR so I'm opening a new issue, dedicated to this use case.

When using SSR, it is common to use different urls on the server and the browser to access the same APIs. It could be only the domain, the path or even the http scheme. The main reason is to reduce network latency by accessing resources directly from a private network.

With the current HTTP transfer cache implementation, the cache key is computed from the HttpRequest with the makeCacheKey function.

This function uses the request url among other things so when the url is different on the server and on the browser, the cached response will never be reused from the transfer cache during hydration.

Related comments / issues: https://github.com/angular/angular/issues/50117#issuecomment-1602675046 https://github.com/angular/angular/issues/50117#issuecomment-1648612777 https://github.com/angular/angular/issues/50117#issuecomment-1732133906 https://github.com/angular/angular/issues/50117#issuecomment-1792077410 https://github.com/angular/universal/issues/1934

First proposed solution: adding more options to HttpRequest and provideClientHydration()

To override the cache key at the request level, we could provide a custom cache key as a request option.

const customCacheKey = makeCustomCacheKey(url, params);
this.http.get(url, { transferCache: { cacheKey: customCacheKey } });

To globally customize the cache key, we could either use an interceptor (Ability to override transferCache property when cloning the request is needed too)

function customCacheKeyInterceptor = (req, next) => {
    const newRequest = req.clone({ 
        transferCache: { 
            cacheKey: customCacheKey(req) // Custom logic possibly using DI to compute the cache key
            ...req.transferCache,
        }
    });
    return next(newRequest);
}

Or we could add a "custom cache key function" option to provideClientHydration():

provideClientHydration({
    customCacheKeyFn: myCustomCacheKeyFn
});

I think that providing a custom cache key function could simplify the usage of and totally replace the existing options includeHeaders, filter and includePostRequests. (but that would be breaking) These options would probably need to be mutually exclusive because they could overlap.

Another useful thing would be to have access to the current makeCacheKey function so we could simply override some parts of the http request to customize the cache key:

import { makeCacheKey } from '@angular/common/http';

function customCacheKey(request: HttpRequest) {
    // If only the url is different between server and browser, we can only override this part
    const overridenRequest = request.clone({ url: 'urlToUseInCacheKey' });
    return makeCacheKey(overridenRequest);
}

function customCacheKeyInterceptor = (req, next) => {
    const newRequest = req.clone({ 
        transferCache: { 
            cacheKey: customCacheKey(request)
            ...req.transferCache,
        }
    });
    return next(newRequest);
}

Second proposed solution: expose transferCacheInterceptorFn and let developers use it where needed

Another way to solve this (closest to the original behavior with TransferHttpCacheModule) would be to expose the transferCacheInterceptorFn in the public API.

We could place this interceptor exactly where we need it in the interceptors chain to control what the request url looks like when cached.

Caching would be enabled by using the interceptor function in provideHttpClient() and calling provideClientHydration().

provideHttpClient(
    withInterceptors([
        authInterceptorFn, // Add Auth header
        transferCacheInterceptorFn, // Cache requests when they are still undifferentiated between browser & server
        overrideUrlInterceptorFn, // Custom logic to override the url on server, specific to the user's use case
])),
provideClientHydration(
    withNoHttpTransferCache(), // disable injecting transferCacheInterceptorFn as the last interceptor
    withHttpTransferCacheOptions({ includePostRequests: true }) // optionally provide global options for transferCacheInterceptorFn 
),

Because the interceptor would still rely on CACHE_OPTIONS which is only provided by provideClientHydration(), we could make sure that the interceptor is used with provideClientHydration() and print a warning/error otherwise.

This approach would require minimal changes to the current implementation without adding any breaking change:

  • Always provide CACHE_OPTIONS even when using withNoHttpTransferCache(),
  • Provide transferCacheInterceptorFn as HTTP_ROOT_INTERCEPTOR_FNS only when withNoHttpTransferCache() is not used,
  • Remove the current error thrown when both withNoHttpTransferCache() and withHttpTransferCacheOptions() are used together.

I believe that this second approach is the best one because it keeps a simple API for simple cases while giving the most flexibility for more advanced use cases. And all of that without any breaking change.

I opened a PR to show how little changes would be needed to implement it.

Alternatives considered

The only alternative is to disable transfer cache from provideClientHydration and rely on a custom caching logic based on an interceptor (like the old TransferHttpCacheModule from @angular/universal.

jsaguet avatar Dec 26 '23 19:12 jsaguet

While it is a very reasonable desire to use local network when doing SSR vs public API endpoints, there are probably existing alternatives.

Couldn't this be solved by a reverse proxy within the same “network” ? (Like if you're using docker).

JeanMeche avatar Dec 26 '23 19:12 JeanMeche

I'm not a network expert so I don't know if it would be feasible or if it would cover every use case.

But anyway, I don't think that the frontend framework should put that much constraint on how a private network that is managed by dedicated teams in big companies should be set up.

jsaguet avatar Dec 26 '23 19:12 jsaguet

I edited the first message to suggest a second approach that would allow more customization for the end user without adding more options to the existing API by exposing the transferCacheInterceptorFn in the public API

jsaguet avatar Dec 27 '23 06:12 jsaguet

I too have a very similar use case where our API domain differs server side and client side. Our Angular application, along with the other applications, are build within Docker containers - most of which are in the same network.

When a server side request is made, we reference use a HTTP interceptor to replace the API domain with the container name so that we directly communicate with the API container. Client side uses the public facing domain.

The main challenges come when we're developing locally and I may have my main webserver running on localhost:8010 for example which does not resolve within the Docker network (only resolves on my host machine). Similarly, the client side would need to use web-api:4040 for example which does not resolve client side (on my host machine).

The requests are going to the same API with the same response, the URLs are the same (expect domain), however, client side rehydration always occurs due to the domain change.

It would be brilliant if we were able to configure provideClientHydration in such a way so that the caching would understand that the 2 domains are "the same".

BenStudee avatar Jan 16 '24 16:01 BenStudee

@BenStudee, the cache only prevents the request from being sent to the backend. Hydration happens either way.

Also, couldn't your dev issues be solved by the CLI proxy ?

JeanMeche avatar Jan 16 '24 16:01 JeanMeche

@BenStudee, the cache only prevents the request from being sent to the backend. Hydration happens either way.

Also, couldn't your dev issues be solved by the CLI proxy ?

Thanks for your response @JeanMeche. Perhaps I'm using the wrong terminology - I am referring to the client executing the HTTP requests when the server has already hydrated the components.

As for CLI proxy - I don't believe so, but if you have an idea in mind and care to share then I am more than happy to listen.

Having the ability to customise how the "cache key" is generated would be enough to overcome the challenge I am facing - at least I believe so.

BenStudee avatar Jan 18 '24 09:01 BenStudee

While it is a very reasonable desire to use local network when doing SSR vs public API endpoints, there are probably existing alternatives.

Couldn't this be solved by a reverse proxy within the same “network” ? (Like if you're using docker).

In some conditions this couldn’t be done (eg your DevOps team can’t do this, or uses k8s with containerd etc, where configs looks different). So giving option to customize cacheKey is the best solution here.

zip-fa avatar Feb 11 '24 06:02 zip-fa

This is exact use case that I have. I really hope PR will be merged.

mbrdar avatar Mar 24 '24 19:03 mbrdar

Hi @AndrewKushnir, do you have any thoughts on this issue and the related PR please ?

It looks like a fairly requested feature based on the reactions and comments and it would be awesome to have some feedback from the angular team given that there is already a PR opened to try to address it.

jsaguet avatar Mar 28 '24 07:03 jsaguet

@jsaguet we had a conversation about this with the team last week and the proposal is to create a new token that would map an internal origin to a public one. The token would only be used on the server - we can add some logic to throw an error (in dev mode) if a token is present on the client.

Here is a quick example of how a token can be provided in the app.config.server.ts:

{
  provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
  useValue: {
    'http://internal-domain.com:1234': 'https://external-domain:443',
    // ...
  }
}

In this case, the HttpTransferCache logic would inject the token while running on the server, check if there is an entry for a given URL and use a public origin info to calculate a cache key. While running on the client, the public version would be used for a cache key, so we can find the necessary data.

Please let us know if that would work for your use-cases.

If you are interested in contributing a PR for this change (since you've created PR #53815, which uses a different approach) - please let us know, we'll be happy to review and provide the necessary feedback.

AndrewKushnir avatar Apr 09 '24 00:04 AndrewKushnir

@AndrewKushnir Thanks for your reply! I think this approach would definitely solve this issue.

We are currently using a map between internal and public domains to implement our own custom transfer cache so this makes sense. It would be easier to use than carefully placing the interceptor in the chain.

I will work on a PR.

jsaguet avatar Apr 09 '24 05:04 jsaguet

This issue has been automatically locked due to inactivity. Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.