From ee2da089ba43cc46989b9d4ab24d2d56c232ed86 Mon Sep 17 00:00:00 2001 From: Jan Hug Date: Tue, 2 Jul 2024 10:11:57 +0200 Subject: [PATCH] Implement client-side caching --- docs/.vitepress/config.ts | 1 + docs/features/caching.md | 144 ++++++++++++++++++ .../app/graphqlMiddleware.serverOptions.ts | 10 +- playground/nuxt.config.ts | 7 + playground/pages/index.vue | 19 ++- playground/pages/user/[id].vue | 3 + playground/server/api/fetch-options.ts | 1 + playground/server/api/server-route.ts | 1 + src/module.ts | 27 +++- src/runtime/composables/nuxtApp.ts | 60 +++++++- .../composables/useAsyncGraphqlQuery.ts | 62 ++++++-- src/runtime/composables/useGraphqlState.ts | 3 +- src/runtime/helpers/ClientCache.ts | 62 ++++++++ .../defineGraphqlServerOptions.ts | 5 +- src/types.ts | 21 ++- 15 files changed, 398 insertions(+), 28 deletions(-) create mode 100644 docs/features/caching.md create mode 100644 src/runtime/helpers/ClientCache.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7b3abc1..a41c7ac 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -50,6 +50,7 @@ export default defineConfig({ text: 'Features', items: [ { text: 'Composables', link: '/features/composables' }, + { text: 'Caching', link: '/features/caching' }, { text: 'Auto Import', link: '/features/auto-import' }, { text: 'Fragments', link: '/features/fragments' }, { text: 'TypeScript', link: '/features/typescript' }, diff --git a/docs/features/caching.md b/docs/features/caching.md new file mode 100644 index 0000000..497e400 --- /dev/null +++ b/docs/features/caching.md @@ -0,0 +1,144 @@ +# Caching + +nuxt-graphql-middleware ships with a client-side cache feature that allows you +to cache individual queries in the browser. The cache is in-memory, so a refresh +automatically "purges" the cache. + +Caching is opt-in, meaning by default nothing is cached. + +## Configuration + +```typescript +export default defineNuxtConfig({ + graphqlMiddleware: { + clientCache: { + // Enable or disable the caching feature. + enabled: true, + // Cache a maximum of 50 queries (default: 100). + maxSize: 50, + } + } +} +``` + +## Usage + +### useAsyncGraphqlQuery + +To opt-in to client side caching, set `client: true` on the `graphqlCaching` +property in the options argument: + +```typescript +const { data } = await useAsyncGraphqlQuery('users', null, { + graphqlCaching: { + client: true, + }, +}) +``` + +Now, the result of the `users` query is stored in memory and any subsequent call +will resolve the cached response. + +In addition, if using SSR, the composable will return the response from the +payload, even when navigating back and forth between a SSR and SPA page. + +## Cache Key + +The cache key is automatically generated using the provided arguments: + +- name of the query +- fetch params (query parameters, includes variables) + +Note that this _does not_ include any other factors that may alter the exact +response! For example, if you use the `useGraphqlState()` feature to define +custom request headers (such as the current language), when the language +changes, the composable will continue to return cached queries for the previous +language. To prevent this, you have to vary the cache key, by either: + +- providing the language as part of the variables +- append the language as part of the fetch params (either using `fetchOptions` + in the composable options or setting global `fetchOptions` via + `useGraphqlState()`) + +## Purging the cache + +The composable creates the cache instance as a property on `NuxtApp`. This +instance is created the first time a cacheable query is made, therefore this +property may be nullable. + +You can purge all cached queries using something like this: + +```typescript +const app = useNuxtApp() + +if (app.$graphqlCache) { + app.$graphqlCache.purge() +} +``` + +## Disable caching + +The module uses +[app config](https://nuxt.com/docs/guide/directory-structure/app-config), so you +can enable or disable the client cache in a plugin: + +```typescript +export default defineNuxtPlugin((NuxtApp) => { + const appConfig = useAppConfig() + appConfig.graphqlMiddleware.clientCacheEnabled = false +} +``` + +## Caching on the server + +Currently caching is not supported server side, but you can easily implement it +yourself using the `doGraphqlRequest` server option method: + +```typescript +import { createStorage } from "unstorage"; +import memoryDriver from "unstorage/drivers/memory"; +import { hash } from 'ohash' + +const storage = createStorage({ + driver: memoryDriver(), +}) + +export default defineGraphqlServerOptions({ + async doGraphqlRequest({ + event, + operation, + operationName, + operationDocument, + variables, + }) { + const key = `${operation}:${operationName}:${hash(variables)}` + const cached = await storage.getItem(key) + + // Get a cached response. + if (cached) { + return cached + } + + const result = await $fetch.raw('https://example.com/graphql', { + method: 'POST' + body: { + query: operationDocument, + variables, + operationName + }, + headers: { + 'custom-header': 'foobar' + } + }) + + // Store item in cache. + storage.setItem(key, result._data) + + return result._data + } +}) +``` + +That way you can also customise the exact behaviour, e.g. only cache certain +queries by name, vary by cookie/session or handle `Set-Cookie` headers from your +backend. diff --git a/playground/app/graphqlMiddleware.serverOptions.ts b/playground/app/graphqlMiddleware.serverOptions.ts index 757fc06..d559a65 100644 --- a/playground/app/graphqlMiddleware.serverOptions.ts +++ b/playground/app/graphqlMiddleware.serverOptions.ts @@ -1,7 +1,12 @@ import { getHeader } from 'h3' import { defineGraphqlServerOptions } from './../../src/runtime/serverOptions/index' +import type { GraphqlResponse } from '../../src/runtime/composables/shared' -export default defineGraphqlServerOptions({ +type GraphqlResponseWithCustomProperty = GraphqlResponse & { + __customProperty?: string[] +} + +export default defineGraphqlServerOptions({ graphqlEndpoint(event, operation, operationName) { if (operationName === 'simulateEndpointDown') { return 'http://invalid/graphql' @@ -34,7 +39,8 @@ export default defineGraphqlServerOptions({ // Return the GraphQL response as is. return { - ...graphqlResponse._data, + data: graphqlResponse._data?.data || null, + errors: graphqlResponse._data?.errors || [], __customProperty: ['one', 'two'], } }, diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index ba2f727..160c86b 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -29,10 +29,17 @@ const graphqlMiddleware: ModuleOptions = { }, }, }, + + clientCache: { + enabled: true, + }, } export default defineNuxtConfig({ modules: [graphqlMiddlewareModule, '@nuxt/devtools'], graphqlMiddleware, ssr: true, + imports: { + autoImport: true, + }, } as any) diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 9a2e9f9..301d939 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -50,11 +50,24 @@ diff --git a/playground/pages/user/[id].vue b/playground/pages/user/[id].vue index 45de70d..d3e2b1b 100644 --- a/playground/pages/user/[id].vue +++ b/playground/pages/user/[id].vue @@ -32,6 +32,9 @@ const { data: user } = await useAsyncGraphqlQuery('userById', variables, { transform: function (v) { return v.data.userById }, + graphqlCaching: { + client: true, + }, }) const title = computed(() => { diff --git a/playground/server/api/fetch-options.ts b/playground/server/api/fetch-options.ts index 24a9063..34c793f 100644 --- a/playground/server/api/fetch-options.ts +++ b/playground/server/api/fetch-options.ts @@ -1,4 +1,5 @@ import { useGraphqlQuery } from '#graphql-composable' +import { defineEventHandler } from 'h3' /** * Custom server route that performs a GraphQL query and returns the mapped diff --git a/playground/server/api/server-route.ts b/playground/server/api/server-route.ts index f699b5c..7c1be15 100644 --- a/playground/server/api/server-route.ts +++ b/playground/server/api/server-route.ts @@ -1,4 +1,5 @@ import { useGraphqlQuery } from '#graphql-composable' +import { defineEventHandler } from 'h3' /** * Custom server route that performs a GraphQL query and returns the mapped diff --git a/src/module.ts b/src/module.ts index 35473e8..e701cdc 100644 --- a/src/module.ts +++ b/src/module.ts @@ -188,6 +188,14 @@ export interface ModuleOptions { * Enable Nuxt DevTools integration. */ devtools?: boolean + + /** + * Client caching configuration. + */ + clientCache?: { + enabled?: boolean + maxSize?: number + } } // Nuxt needs this. @@ -338,6 +346,11 @@ export default defineNuxtModule({ serverApiPrefix: options.serverApiPrefix!, } + nuxt.options.appConfig.graphqlMiddleware = { + clientCacheEnabled: !!options.clientCache?.enabled, + clientCacheMaxSize: options.clientCache?.maxSize || 100, + } + nuxt.options.runtimeConfig.graphqlMiddleware = { graphqlEndpoint: options.graphqlEndpoint || '', } @@ -478,7 +491,10 @@ declare module '#graphql-documents' { }) nuxt.hook('nitro:build:before', (nitro) => { nuxt.hook('builder:watch', async (_event, path) => { - path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)) + path = relative( + nuxt.options.srcDir, + resolve(nuxt.options.srcDir, path), + ) // We only care about GraphQL files. if (!path.match(/\.(gql|graphql)$/)) { return @@ -502,3 +518,12 @@ declare module '#graphql-documents' { } }, }) + +declare module 'nuxt/schema' { + interface AppConfig { + graphqlMiddleware: { + clientCacheEnabled: boolean + clientCacheMaxSize: number + } + } +} diff --git a/src/runtime/composables/nuxtApp.ts b/src/runtime/composables/nuxtApp.ts index 07918d3..8b34635 100644 --- a/src/runtime/composables/nuxtApp.ts +++ b/src/runtime/composables/nuxtApp.ts @@ -1,22 +1,72 @@ import type { FetchOptions } from 'ofetch' import { useGraphqlState } from './useGraphqlState' import { type GraphqlResponse, getEndpoint } from './shared' +import { hash } from 'ohash' +import { GraphqlMiddlewareCache } from '../helpers/ClientCache' + +type RequestCacheOptions = { + client?: boolean +} export function performRequest( operation: string, operationName: string, method: 'get' | 'post', options: FetchOptions, + cacheOptions?: RequestCacheOptions, ): Promise> { const state = useGraphqlState() - return $fetch>(getEndpoint(operation, operationName), { - ...(state && state.fetchOptions ? state.fetchOptions : {}), - ...options, - method, - }).then((v) => { + const app = useNuxtApp() + + // The cache key. + const key = `${operation}:${operationName}:${hash(options.params)}` + + // Try to return a cached query if possible. + if (cacheOptions) { + const config = useAppConfig() + // Handle caching on client. + if ( + import.meta.client && + cacheOptions.client && + config.graphqlMiddleware.clientCacheEnabled + ) { + if (!app.$graphqlCache) { + app.$graphqlCache = new GraphqlMiddlewareCache( + config.graphqlMiddleware.clientCacheMaxSize, + ) + } + + const cached = app.$graphqlCache.get>>(key) + + if (cached) { + return cached + } + } + } + + const promise = $fetch>( + getEndpoint(operation, operationName), + { + ...(state && state.fetchOptions ? state.fetchOptions : {}), + ...options, + method, + }, + ).then((v) => { return { data: v.data, errors: v.errors || [], } }) + + if (import.meta.client && cacheOptions?.client && app.$graphqlCache) { + app.$graphqlCache.set(key, promise) + } + + return promise +} + +declare module '#app' { + interface NuxtApp { + $graphqlCache?: GraphqlMiddlewareCache + } } diff --git a/src/runtime/composables/useAsyncGraphqlQuery.ts b/src/runtime/composables/useAsyncGraphqlQuery.ts index 6a860a4..58f3683 100644 --- a/src/runtime/composables/useAsyncGraphqlQuery.ts +++ b/src/runtime/composables/useAsyncGraphqlQuery.ts @@ -14,36 +14,60 @@ import type { AsyncData, AsyncDataOptions } from 'nuxt/app' import { useAsyncData } from '#imports' import { hash } from 'ohash' +type AsyncGraphqlQueryOptions< + ResponseType, + DefaultT, + Keys extends KeysOf, + F, +> = AsyncDataOptions & { + graphqlCaching?: { + client?: boolean + } + fetchOptions?: F +} + /** * Wrapper for useAsyncData to perform a single GraphQL query. */ export function useAsyncGraphqlQuery< + // The name of the query. Name extends GraphqlMiddlewareQueryName, + // The type for the variables. VarType extends GraphqlMiddlewareQuery[Name][0], + // Whether the variables argument is optional or not. VarsOptional extends GraphqlMiddlewareQuery[Name][1], + // The type for the query response. ResponseType extends GraphqlResponse, + // Type for the $fetch options. F extends FetchOptions<'json'>, + // The type for the transformed/picked/defaulted response of useAsyncData. DefaultT = ResponseType, + // Possible keys for the "pick" option. Keys extends KeysOf = KeysOf, >( name: Name, + // Arguments are optional, so the method signature makes it optional. ...args: VarsOptional extends true ? [ (undefined | null | {} | VarType | Ref)?, - AsyncDataOptions?, - F?, + AsyncGraphqlQueryOptions?, ] : [ VarType | Ref, - (undefined | null | AsyncDataOptions)?, - F?, + ( + | undefined + | null + | AsyncGraphqlQueryOptions + )?, ] ): AsyncData, GraphqlResponseError[] | null> { const variables = args[0] const asyncDataOptions = args[1] || {} - const fetchOptions = args[2] || {} + const fetchOptions = asyncDataOptions.fetchOptions const key = `graphql:${name}:${hash(unref(variables))}` + const app = useNuxtApp() + // If the variables are reactive, watch them. if (variables && isRef(variables)) { if (!asyncDataOptions.watch) { @@ -53,13 +77,33 @@ export function useAsyncGraphqlQuery< asyncDataOptions.watch.push(variables) } + // On the client side, if client caching is requested, we can directly return + // data from the payload if possible. + if ( + import.meta.client && + asyncDataOptions.graphqlCaching?.client && + !asyncDataOptions.getCachedData + ) { + asyncDataOptions.getCachedData = function (key) { + return app.payload.data[key] + } + } + + const cacheOptions = asyncDataOptions.graphqlCaching + return useAsyncData( key, () => - performRequest('query', name, 'get', { - params: buildRequestParams(unref(variables)), - ...fetchOptions, - }) as any, + performRequest( + 'query', + name, + 'get', + { + params: buildRequestParams(unref(variables)), + ...fetchOptions, + }, + cacheOptions, + ) as any, asyncDataOptions, ) as any } diff --git a/src/runtime/composables/useGraphqlState.ts b/src/runtime/composables/useGraphqlState.ts index f5442de..332ac48 100644 --- a/src/runtime/composables/useGraphqlState.ts +++ b/src/runtime/composables/useGraphqlState.ts @@ -1,4 +1,5 @@ -import { type NuxtApp, useNuxtApp } from '#app' +import { useNuxtApp } from '#imports' +import { type NuxtApp } from '#app' import { type GraphqlMiddlewareState } from './../../types' export const useGraphqlState = function ( diff --git a/src/runtime/helpers/ClientCache.ts b/src/runtime/helpers/ClientCache.ts new file mode 100644 index 0000000..62b3bf1 --- /dev/null +++ b/src/runtime/helpers/ClientCache.ts @@ -0,0 +1,62 @@ +/** + * Simple LRU (least recently used) cache. + * + * Keeps the amount of cached items limited to the given max size. + * Once that number is reached, the cache item used the least is removed. + */ +export class GraphqlMiddlewareCache { + cache: Record + keys: string[] + maxSize: number + + constructor(maxSize: number = 100) { + this.cache = {} + this.keys = [] + this.maxSize = maxSize + } + + set(key: string, value: unknown): void { + if (Object.prototype.hasOwnProperty.call(this.cache, key)) { + // Key already exists, remove it from the current position + // because it will be pushed to the end of the array below. + const index = this.keys.indexOf(key) + if (index > -1) { + this.keys.splice(index, 1) + } + } else if (this.keys.length >= this.maxSize) { + // If we've reached the limit of our cache, remove the oldest entry. + const oldestKey = this.keys.shift() + if (oldestKey !== undefined) { + delete this.cache[oldestKey] + } + } + + // Add the cache entry. + this.cache[key] = value + + // Add the key to the end to mark it as the most recently used. + this.keys.push(key) + } + + get(key: string): T | undefined { + const value = this.cache[key] + if (value !== undefined) { + // Move the key to the end to mark it as the most recently used. + const index = this.keys.indexOf(key) + if (index > -1) { + // Remove the key from its current position. + this.keys.splice(index, 1) + + // Push it to the end. + this.keys.push(key) + } + return value as T + } + + return undefined + } + + purge() { + this.cache = {} + } +} diff --git a/src/runtime/serverOptions/defineGraphqlServerOptions.ts b/src/runtime/serverOptions/defineGraphqlServerOptions.ts index c6247cd..8bee461 100644 --- a/src/runtime/serverOptions/defineGraphqlServerOptions.ts +++ b/src/runtime/serverOptions/defineGraphqlServerOptions.ts @@ -1,7 +1,8 @@ +import type { GraphqlResponse } from '../composables/shared' import { type GraphqlMiddlewareServerOptions } from './../../types' -export function defineGraphqlServerOptions( - options: GraphqlMiddlewareServerOptions, +export function defineGraphqlServerOptions>( + options: GraphqlMiddlewareServerOptions, ) { return options } diff --git a/src/types.ts b/src/types.ts index 8e5b8cf..9a10f5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import type { H3Event } from 'h3' import type { FetchOptions, FetchResponse, FetchError } from 'ofetch' import type { GraphQLError } from 'graphql' +import type { GraphqlResponse } from './runtime/composables/shared' export type GraphqlMiddlewareGraphqlEndpointMethod = ( event?: H3Event, @@ -14,12 +15,12 @@ export type GraphqlMiddlewareServerFetchOptionsMethod = ( operationName?: string, ) => FetchOptions | Promise -export type GraphqlMiddlewareOnServerResponseMethod = ( +export type GraphqlMiddlewareOnServerResponseMethod = ( event: H3Event, - response: FetchResponse, + response: FetchResponse, operation?: string, operationName?: string, -) => any | Promise +) => T | Promise export type GraphqlMiddlewareOnServerErrorMethod = ( event: H3Event, @@ -62,10 +63,12 @@ export type GraphqlMiddlewareDoRequestMethod = ( /** * Configuration options during runtime. */ -export type GraphqlMiddlewareServerOptions = { +export type GraphqlMiddlewareServerOptions> = { /** * Custom callback to return the GraphQL endpoint per request. * + * The method is only called if no `doGraphqlRequest` method is implemented. + * * @default undefined * * @example @@ -81,6 +84,8 @@ export type GraphqlMiddlewareServerOptions = { /** * Provide the options for the ofetch request to the GraphQL server. * + * The method is only called if no `doGraphqlRequest` method is implemented. + * * @default undefined * * @example @@ -102,6 +107,8 @@ export type GraphqlMiddlewareServerOptions = { /** * Handle the response from the GraphQL server. * + * The method is only called if no `doGraphqlRequest` method is implemented. + * * You can alter the response, add additional properties to the data, get * and set headers, etc. * @@ -127,11 +134,13 @@ export type GraphqlMiddlewareServerOptions = { * } * ``` */ - onServerResponse?: GraphqlMiddlewareOnServerResponseMethod + onServerResponse?: GraphqlMiddlewareOnServerResponseMethod /** * Handle a fetch error from the GraphQL request. * + * The method is only called if no `doGraphqlRequest` method is implemented. + * * Note that errors are only thrown for responses that are not status * 200-299. See https://github.com/unjs/ofetch#%EF%B8%8F-handling-errors for * more information. @@ -164,6 +173,8 @@ export type GraphqlMiddlewareServerOptions = { * This can be used if onServerError, onServerResponse, serverFetchOptions * and graphqlEndpoint are not enough to meet your requirements. * + * When this method is implemented, all other methods are not called. + * * The method will be called in the /api/graphql server route and should * perform the GraphQL request and return the response. *