Skip to content

Commit

Permalink
Implement client-side caching
Browse files Browse the repository at this point in the history
  • Loading branch information
dulnan committed Jul 2, 2024
1 parent 88e591d commit ee2da08
Show file tree
Hide file tree
Showing 15 changed files with 398 additions and 28 deletions.
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
144 changes: 144 additions & 0 deletions docs/features/caching.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions playground/app/graphqlMiddleware.serverOptions.ts
Original file line number Diff line number Diff line change
@@ -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<any> & {
__customProperty?: string[]
}

export default defineGraphqlServerOptions<GraphqlResponseWithCustomProperty>({
graphqlEndpoint(event, operation, operationName) {
if (operationName === 'simulateEndpointDown') {
return 'http://invalid/graphql'
Expand Down Expand Up @@ -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'],
}
},
Expand Down
7 changes: 7 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
19 changes: 16 additions & 3 deletions playground/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,24 @@
<script setup lang="ts">
import { useGraphqlMutation } from '#imports'
const { data: users } = await useAsyncGraphqlQuery('users', null, {
const app = useNuxtApp()
const { data: users, refresh } = await useAsyncGraphqlQuery('users', null, {
transform: (v) => v.data.users,
graphqlCaching: {
client: true,
},
})
function deleteUser(id: number) {
useGraphqlMutation('deleteUser', { id })
function purgeCache() {
if (app.$graphqlCache) {
app.$graphqlCache.purge()
}
}
async function deleteUser(id: number) {
await useGraphqlMutation('deleteUser', { id })
purgeCache()
await refresh()
}
</script>
3 changes: 3 additions & 0 deletions playground/pages/user/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const { data: user } = await useAsyncGraphqlQuery('userById', variables, {
transform: function (v) {
return v.data.userById
},
graphqlCaching: {
client: true,
},
})
const title = computed(() => {
Expand Down
1 change: 1 addition & 0 deletions playground/server/api/fetch-options.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions playground/server/api/server-route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
27 changes: 26 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ export interface ModuleOptions {
* Enable Nuxt DevTools integration.
*/
devtools?: boolean

/**
* Client caching configuration.
*/
clientCache?: {
enabled?: boolean
maxSize?: number
}
}

// Nuxt needs this.
Expand Down Expand Up @@ -338,6 +346,11 @@ export default defineNuxtModule<ModuleOptions>({
serverApiPrefix: options.serverApiPrefix!,
}

nuxt.options.appConfig.graphqlMiddleware = {
clientCacheEnabled: !!options.clientCache?.enabled,
clientCacheMaxSize: options.clientCache?.maxSize || 100,
}

nuxt.options.runtimeConfig.graphqlMiddleware = {
graphqlEndpoint: options.graphqlEndpoint || '',
}
Expand Down Expand Up @@ -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
Expand All @@ -502,3 +518,12 @@ declare module '#graphql-documents' {
}
},
})

declare module 'nuxt/schema' {
interface AppConfig {
graphqlMiddleware: {
clientCacheEnabled: boolean
clientCacheMaxSize: number
}
}
}
60 changes: 55 additions & 5 deletions src/runtime/composables/nuxtApp.ts
Original file line number Diff line number Diff line change
@@ -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<GraphqlResponse<any>> {
const state = useGraphqlState()
return $fetch<GraphqlResponse<any>>(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<Promise<GraphqlResponse<any>>>(key)

if (cached) {
return cached
}
}
}

const promise = $fetch<GraphqlResponse<any>>(
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
}
}
Loading

0 comments on commit ee2da08

Please sign in to comment.