dulnan / nuxt-multi-cache

Advanced caching of components, routes and data for Nuxt 3. Dynamically define CDN cache control headers. Provides cache management API for purging items by key or using cache tags.
https://nuxt-multi-cache.dulnan.net
MIT License
190 stars 15 forks source link

useCachedAsyncData composable #25

Open or2e opened 11 months ago

or2e commented 11 months ago

I may be wrong, but most often we have to deal with asyncData To avoid routine, I suggest creating a wrap-composable useCachedAsyncData

Example ``` import type { NuxtApp, AsyncDataOptions } from 'nuxt/app'; import type { KeysOf } from 'nuxt/dist/app/composables/asyncData'; export function useCachedAsyncData< ResT, DataT = ResT, PickKeys extends KeysOf = KeysOf, DefaultT = null >( key: string, handler: (ctx?: NuxtApp) => Promise, options: AsyncDataOptions & { cacheKey: string; cacheTags: string[]; cacheExpires?: number; } ) { // We need to cache transformed value to prevent value from being transformed every time. const transform = options?.transform; // Remove transform from options, so useAsyncData doesn't transform it again const optionsWithoutTransform = { ...options, transform: undefined }; return useAsyncData( key, async () => { const { value, addToCache } = await useDataCache< DataT | Awaited >(options.cacheKey); if (value) { return value; } const _result = await handler(); const result = transform ? transform(_result) : _result; addToCache(result, options.cacheTags, options.cacheExpires); return result; }, optionsWithoutTransform ); } ```
dulnan commented 11 months ago

This sounds interesting, I will give this a try!

Crease29 commented 3 weeks ago

Oh yes this would be so handy!

dulnan commented 5 days ago

@or2e I'm now looking into implementing this. Was there a particular reason you added the cacheKey as an option instead of using the key argument provided? Just wondering, maybe I'm not thinking of a use case :smile:

or2e commented 5 days ago

99% of the time they match

dulnan commented 5 days ago

Right - so I guess it would be fine to reuse that key and make it required.

For the cacheExpires and cacheTags options: I thought about making these methods, that receive the untransformed result. That way cacheability metadata like tags and expires coming from a backend response could be used.

dulnan commented 5 days ago

@or2e @Crease29 I've implemented it now and opened a PR, if you like you can take a look and tell me if the implementation makes sense :smile: The docs are here: https://deploy-preview-58--nuxt-multi-cache.netlify.app/composables/useCachedAsyncData

Crease29 commented 5 days ago

Thank you so much! :)

Regarding the documentation: In the very first example, I'd actually replace weather with users and unify the cache key with the full example. In the full example I'd personally prefer showing the use with static cache tags, I think that's more common than the response including cache tags.

bgondy commented 5 days ago

It seems your implementation uses the regular useAsyncData() composable in client side. IMHO, it would be nice to also have a kind of memoization with a TTL by using transform() and getCachedData().

That's what I've done in my current project.

Here is the implementation :

```ts // useCacheableAsyncData.ts import { toRef, toValue } from 'vue'; import { callWithNuxt, type NuxtApp } from '#app'; import type { AsyncDataOptions, KeysOf } from '#app/composables/asyncData'; import { useDataCache } from '#nuxt-multi-cache/composables'; import { assertValidCacheKey, assertValidTtl } from '~/lib/cache-utils'; export interface TimestampedPayload { payload: T; issuedAt: number; } function wrapPayloadWithTimestamp(payload: T, issuedAt = Date.now()): TimestampedPayload { return { payload, issuedAt, }; } function unwrapTimestampedPayload({ payload }: TimestampedPayload): T { return payload; } export type CachedAsyncDataOptions< // eslint-disable-next-line unicorn/prevent-abbreviations ResT, DataT = ResT, PickKeys extends KeysOf = KeysOf, DefaultT = null, > = Omit, 'transform' | 'getCachedData'> & { /** * Time To Live in milliseconds * @example 60_000 for 1 min */ ttl?: number; cacheTags?: string[] | ((response: ResT) => string[]); }; export default async function useCacheableAsyncData< // eslint-disable-next-line unicorn/prevent-abbreviations ResT, NuxtErrorDataT = unknown, DataT extends TimestampedPayload = TimestampedPayload, PickKeys extends KeysOf = KeysOf, DefaultT = DataT, >( key: string, handler: (context?: NuxtApp) => Promise, options?: CachedAsyncDataOptions, ) { const { ttl, cacheTags = [], ...otherOptions } = options ?? {}; if (ttl !== undefined) { assertValidTtl(ttl); } assertValidCacheKey(key); const { data, ...others } = await useAsyncData< ResT, NuxtErrorDataT, TimestampedPayload | undefined, PickKeys, DefaultT >( key, async (nuxtApp) => { const { value, addToCache } = await useDataCache(key); if (value) { return value; } const response = nuxtApp === undefined ? await handler(nuxtApp) : await callWithNuxt(nuxtApp, handler, [nuxtApp]); await addToCache( response, Array.isArray(cacheTags) ? cacheTags : cacheTags(response), ttl ? ttl / 1000 : undefined, ); return response; }, { ...otherOptions, transform(input) { return wrapPayloadWithTimestamp(input); }, getCachedData(key, nuxtApp) { const data: DataT | undefined = (nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]) as DataT | undefined; // No data in payload if (data === undefined) { return; } if (ttl !== undefined && data.issuedAt + ttl < Date.now()) { return; } return data; }, }, ); // data.value cannot be undefined at this point. Using `as` to fix type. Maybe this is a typing issue in Nuxt source code const value = toValue(data.value) as TimestampedPayload; return { data: toRef(() => unwrapTimestampedPayload(value)), ...others }; } ``` Usage: ```ts const { data } = useCacheableAsyncData( 'some:cache:key', async () => { return Promise.resolve('Some data'); }, { deep: false, ttl: import.meta.server ? CACHE_TTL_MS_12_HOURS : CACHE_TTL_MS_15_MIN, }, ); ```

Using import.meta.server, I have even been able to specify different TTLs for server and client (as they have their own bundle) with way shorter TTLs in client.

I didn't found any downside for now.

WDYT ?

dulnan commented 4 days ago

@bgondy I actually have been thinking about extending useDataCache to the client-side as well. And indeed your approach with a maxAge that would also apply to the client side is a nice idea. My main concern is (the classic) question of cache invalidation client side. Obviously a simple browser refresh always "purges" the cache. But imho it would have to work a bit like useAsyncData's clear and refresh.

In this case here, however, since the underlying useAsyncData already does some rudimentary "caching" anyway, we could indeed add client-side caching here. Especially since the name useCachedAsyncData basically implies that things will be cached. I will give this a try.

dulnan commented 4 days ago

Alright, I gave this a shot and added client-side caching. I've used the example from @bgondy as a basis, but changed the behaviour:

I first wanted to have a single maxAge option for both client and server side. But the problem is that, when a method is provided, it can only receive the full (untransformed) data during SSR. On the client that full untransformed result is obviously not available anymore. The argument would have to be optional, but that's a bit annoying.

Let me know if this makes sense. And a big thanks to all for all the constructive feedback and ideas!

bgondy commented 4 days ago

It also stores and gets subsequent client-side handler results in nuxtApp.static.data

This is so cool !

LGTM