nitro icon indicating copy to clipboard operation
nitro copied to clipboard

Support serializable options to configure caching route rules

Open pi0 opened this issue 2 years ago • 21 comments

Related: https://github.com/unjs/nitro/issues/977

Since route rules only accept serializable options, and for best DX, we can introduce a configuration API that defines caching route rules as an alternative to non-serializable options (shouldInvalidateCache ~> invalidateHeaders, getKey ~> keyTemplate)

pi0 avatar Mar 13 '23 13:03 pi0

I am concerned that keyTemplate is not flexible enough to replace getKey. How about defining custom cache rulesets and handlers in code and just specify the ruleset id in configuration?

ah-dc avatar May 24 '23 05:05 ah-dc

I believe this is something that I need but I may be misunderstanding so sorry if I am.

My use case is that I'm using ISR to keep my site cached as I'm using a headless CMS to serve content. I'm using ISR as a way to serve the content ASAP. However, I'd also like it if a user publishes new content/changes in Storyblok (CMS), i could selectively/programmatically invalidate the cache so that the published changes are reflected in the live site.

Not sure if the above use case is related to this but it was something I've been searching for and this seemed the closest.

gazreyn avatar May 31 '23 13:05 gazreyn

Is there any new development?

lxccc812 avatar Feb 23 '24 08:02 lxccc812

We are exploring solutions to bypass caching for logged-in users within our Nuxt 3 application. It would be ideal to have a feature that allows specifying conditions under which route caching should be bypassed, such as the presence of an authentication cookie.

@pi0 Is there currently any method or workaround available to selectively disable caching under certain conditions? For instance, could we leverage Nitro server middleware to achieve this?

jony1993 avatar Mar 29 '24 09:03 jony1993

@jony1993 Not with route rules yet but you can simply replace route rule with defineCachedEventHandler with shouldInvalidateCache.

pi0 avatar Mar 29 '24 09:03 pi0

@jony1993 I just wrote a nitro plugin that imports the nuxt handler and manually wraps it with a cachedEventHandler call and pass the options that way

MiniDigger avatar Mar 29 '24 09:03 MiniDigger

@pi0 Thanks for the quick response.

You mean something like this? (for example in server/middleware/cache.ts)

export default defineCachedEventHandler(() => {}, {
  swr: true,
  maxAge: 60 * 60,
  shouldBypassCache: (event) => {
    // Check for an authentication cookie to determine whether to bypass the cache
    const auth = getCookie(event, 'auth')
    try {
      // Attempt to parse the cookie string into an object
      const authCookieObject = JSON.parse(auth)

      // Check the isAuthenticated property
      return authCookieObject.isAuthenticated
    }
    catch (error) {
      // In case of error (e.g., parsing error), assume the user is not authenticated
      // Bypass the cache if we can't parse the cookie
      return false
    }
  },
})

jony1993 avatar Mar 29 '24 13:03 jony1993

@pi0 so how would this look like? Version of @jony1993 is not working. I'm using Nitro with Nuxt3

swissmexxa avatar May 08 '24 14:05 swissmexxa

@MiniDigger Could you share the code of your plugin you mentioned above?

swissmexxa avatar May 09 '24 11:05 swissmexxa

@swissmexxa its, uhhm, not pretty but it works, lol been running this in prod for like a year now, it kinda goes like this

export default defineNitroPlugin((nitroApp) => {
  // find our handler
  const h = (eval("handlers") as HandlerDefinition[]).find((ha) => ha.route === "/**");
  if (!h) {
    nitroLog("Not enabling ISG cause we didn't find a matching handler");
    return;
  }

  const handler = cachedEventHandler(lazyEventHandler(h.handler), {
      group: "pages",
      swr: false,
      maxAge: 30 * 60,
      name: "pagecache",
      getKey,
      shouldInvalidateCache,
      shouldBypassCache,
    } as CacheOptions);

  nitroLog("installing handler", h.route);
  nitroApp.router.use(h.route, handler, h.method);

and then I just have methods like this:

function shouldInvalidateCache(e: H3Event)
function shouldBypassCache(e: H3Event)
function getKey(e: H3Event)

bascially, I rely on nitro packaging the handles and my plugin into the same file and that nitro defines a handlers variable in that file, in an accessible scope. then I just need to find my handler (which is the one with the route /**) and can reregister it into the nitro router with my desired settings. need to use eval so that vite doesn't rename the handlers variable for conflict. hope this helps.

MiniDigger avatar May 10 '24 06:05 MiniDigger

@swissmexxa its, uhhm, not pretty but it works, lol been running this in prod for like a year now, it kinda goes like this

export default defineNitroPlugin((nitroApp) => {
  // find our handler
  const h = (eval("handlers") as HandlerDefinition[]).find((ha) => ha.route === "/**");
  if (!h) {
    nitroLog("Not enabling ISG cause we didn't find a matching handler");
    return;
  }

  const handler = cachedEventHandler(lazyEventHandler(h.handler), {
      group: "pages",
      swr: false,
      maxAge: 30 * 60,
      name: "pagecache",
      getKey,
      shouldInvalidateCache,
      shouldBypassCache,
    } as CacheOptions);

  nitroLog("installing handler", h.route);
  nitroApp.router.use(h.route, handler, h.method);

and then I just have methods like this:

function shouldInvalidateCache(e: H3Event)
function shouldBypassCache(e: H3Event)
function getKey(e: H3Event)

bascially, I rely on nitro packaging the handles and my plugin into the same file and that nitro defines a handlers variable in that file, in an accessible scope. then I just need to find my handler (which is the one with the route /**) and can reregister it into the nitro router with my desired settings. need to use eval so that vite doesn't rename the handlers variable for conflict. hope this helps.

Please forgive me for not understanding the example you wrote. In which folder is this example written? What if I want to customize the cache key based on the full URL of the current page, or non-parameters in the URL?

I tried printing the path information, but unfortunately, it didn't output anything. image

lxccc812 avatar May 14 '24 08:05 lxccc812

@MiniDigger Thank you very much for your example

@lxccc812 This file would be placed inside the server/plugins folder. The defineCachedEventHandler function has an option object as second parameter where you can set a getKey function. This function will receive the event object as parameter where you can get the request URL and Query parameter as follow:

function getKey(event: Event): string {
  const currentUrl = getRequestURL(event);
  const queries = getQuery(event);
  ... your own logic
  return theKey;
}

Then you can create your own logic for creating a string key and return it inside the getKey function.

I have found a quite simple solution to just disable the generation of the Nuxt cache under certain conditions in Nuxt 3 with routeRules if this already helps you:

server/plugins/nuxt-cache-invalidator.ts

export default defineNitroPlugin(nitroApp => {
  nitroApp.hooks.hook('render:response', (response, { event }) => {
    const queries = getQuery(event);
    const shouldNotCreateCache = <HERE YOUR OWN LOGIC>

    if (shouldNotCreateCache)
      response['headers'] = {
        ...(response['headers'] ?? {}),
        'cache-control': 'no-store',
        'last-modified': 'undefined', // required so Nuxt cache is not valid and does not create a cache version
      };
    else
      response['headers'] = { ...(response['headers'] ?? {}), 'cache-control': 'max-age=300, stale-while-revalidate' };
  });
});

nuxt.config.ts

routeRules: {
    '/**': { cache: {} }, // do not set any props or values from plugin will be overwritten
  },

swissmexxa avatar May 14 '24 09:05 swissmexxa

I used define Cache EventHandler to specify the value of key, but it doesn’t seem to work. image

lxccc812 avatar May 15 '24 03:05 lxccc812

@lxccc812 Define your routeRules directly in the root of your nuxt.config object and not under nitro.

I made a stackblitz example for you: https://stackblitz.com/edit/github-v24i7v-dipodp

You can call any sub route (ex. /i-am-a-cached-page) and it will create a cached page file under .nuxt/cache/nitro/routes/_. But if you add the query parameter ?dont-cache (ex. /do-not-cache-me?dont-cache it won't generate a cache file under .nuxt/cache/nitro/routes/_ for the page.

My solution is only to prevent the generation of a cache entry by Nuxt under certain conditions (for example when a user is logged in or you are in preview mode of a CMS with query params). If you want to also change the structure of the generated cache key you will have to have a look at the solution of MiniDigger.

Hope it helps

swissmexxa avatar May 15 '24 04:05 swissmexxa

@lxccc812 Define your routeRules directly in the root of your nuxt.config object and not under nitro.

I made a stackblitz example for you: https://stackblitz.com/edit/github-v24i7v-dipodp

You can call any sub route (ex. /i-am-a-cached-page) and it will create a cached page file under .nuxt/cache/nitro/routes/_. But if you add the query parameter ?dont-cache (ex. /do-not-cache-me?dont-cache it won't generate a cache file under .nuxt/cache/nitro/routes/_ for the page.

My solution is only to prevent the generation of a cache entry by Nuxt under certain conditions (for example when a user is logged in or you are in preview mode of a CMS with query params). If you want to also change the structure of the generated cache key you will have to have a look at the solution of MiniDigger.

Hope it helps

Thank you for your patient answer, but for me, what I need more is how to customize the cache key name.

/a/b/[slug].vue /a/b/c?id=1 /a/b/d?id=2 I usually need to get variables and params to customize the cache key name, I will look at the example written by MiniDigger again for research.

Thanks again, I also learned a lot from your answers.

lxccc812 avatar May 15 '24 06:05 lxccc812

@lxccc812 I made you another stackblitz including an example of MiniDigger: https://stackblitz.com/edit/github-v24i7v-kkln93

You have the same behavior from the stackblitz of the previous comment but now there is additional code in server/plugins/cache-keys.ts. If you visit a subpage under /en/... it will use a custom cache key (using pathname and query params) and create a cache file under ./nuxt/cache/pages/en

swissmexxa avatar May 15 '24 07:05 swissmexxa

@swissmexxa Thank you for your example. When I tested the example you gave me, I found that if I have multiple routes that need to be cached, how should I accurately obtain the handler relative to the page route?

I printed the handlerList, which includes all routes with cache declared in routeRules.

I modified your example a little bit, the address is here Example

Now I can only customize the cache key name of route a

lxccc812 avatar May 15 '24 10:05 lxccc812

@lxccc812 with this

const enHandler = handlerList.find(
    (r) => r.route === '/a' || r.route === '/b'
  );

you search an element in a list where route is either /a or /b. So it will stop at /a because it comes first in the list and return it. Therefore it will always return the handler for /a and not /b.

You will have to define code for every route you define in routeRules.

rough example:

 const aHandler = handlerList.find((r) => r.route === '/a');
 const bHandler = handlerList.find((r) => r.route === '/b');

 if (aHandler) {
   customHandler = cachedEventHandler(...
   nitroApp.router.use(aHandler.route, customHandler, aHandler.method);
 if(bHandler) {
   customHandler = cachedEventHandler(...
   nitroApp.router.use(bHandler.route, customHandler, bHandler.method);
 }

For the example of an earlier comment above with these pages:

/a/b/[slug].vue
/a/b/c?id=1
/a/b/d?id=2

The routeRules would be

'/**': { cache: {} }, // matching all routes except more specific below
'/a/**': { cache: {} }, // matching all subroutes of a/ except more specific below
'/a/b/**': { cache: {} }, // matching all subroutes of a/b/ except more specific below
'/a/b/c': { cache: {} }, // matching exact subroute a/b/c

and would get handlers as follow:

 const starHandler = handlerList.find((r) => r.route === '/**');
 const aStarHandler = handlerList.find((r) => r.route === '/a/**');
 const abStarHandler = handlerList.find((r) => r.route === '/a/b/**');
 const abcHandler = handlerList.find((r) => r.route === '/a/b/c');

or you could just add one routeRule /** with one handler and decide in the getKey method of the one handler how the key should be created based on the path which you can get with the H3Event parameter of the getKey function.

swissmexxa avatar May 15 '24 11:05 swissmexxa

@swissmexxa Thank you again for your patient answer.

The sign to generate cache is to enter the getKey function, so what is the sign to use cache?In this example, when I click on any link, it goes into the function getKey and returns the custom string. Does this mean that I create the cache on each click instead of using the cache generated on the first click on the second click.

console.log image

Does this mean that the cache has been used? image

If I have two or more users accessing page/a on different devices, then when the different users access the page again (a second request to the server), will they retrieve it from cache? Return a response to reduce server pressure.

lxccc812 avatar May 16 '24 03:05 lxccc812

I found a new problem. If I access ?t=2 and successfully generate cache with t variable, then when I access ?t=2&x=1 again, it will automatically jump to ?t=2, which is not what I want. What I need is access to ?t=2&x=1 or other similar links with multiple indeterminate parameters. It will return the cache of ?t=2 and keep the original link without redirecting or jumping to the ?t=2 link.

image

You can see a related demo here

I also wrote a workaround, but I'm not sure if it will bypass the cache and access the server directly. My understanding of this sentence from the documentation: "A function that returns a boolean value to bypass the current cache without invalidating existing entries". That is, bypassing the step of generating the cache and continuing to use the current cache.If I understand correctly, my method is valid.

shouldBypassCache: (event: H3Event) => {

  const queries = getQuery(event);

  const cacheKeys = ['t'];

  const queryKeys = Object.keys(queries);

  const hasOtherQuery = queryKeys.some(
    (queryKey) => !cacheKeys.includes(queryKey)
  );

  return hasOtherQuery;
},

lxccc812 avatar May 16 '24 09:05 lxccc812

in lieu of an official solution - I thought I'd add what I've done (building on other contributors' insights here):

*Updated for use with Nuxt v3.14

// nuxt.config.ts
...
routeRules: {
  '/your-path/**': {
    cache: {} // just a placeholder that we can augment in our server-plugin
  }
}
// server/plugins/your-handler.ts 

import { parseURL } from 'ufo'
import { hash } from 'ohash'
// @ts-expect-error virtual file
import { handlers } from '#nitro-internal-virtual/server-handlers' // eslint-disable-line import/no-unresolved
import type { NitroEventHandler } from 'nitropack'

function escapeKey(key: string | string[]) {
  return String(key).replace(/\W/g, '')
}

export default defineNitroPlugin(nitroApp => {
  const foundHandler = (handlers as HandlerDefinition[]).find(
    ha => ha.route === '/your-path/**' // < Whatever pattern you're looking to cache
  )
  if (!foundHandler || !foundHandler.route) return

  const handler = cachedEventHandler(lazyEventHandler(foundHandler.handler as any), {
    swr: true,
    maxAge: 3600,

    getKey: async event => {
      // This is mainly yoinked from the default nitro `getKey`
      // (https://github.com/unjs/nitro/blob/v2/src/runtime/internal/cache.ts - pathname + hashed props)
      
      const path =
        event.node.req.originalUrl || event.node.req.url || event.path
      const decodedPath = decodeURI(parseURL(path).pathname)

      const pathname =
        escapeKey(decodeURI(parseURL(path).pathname)).slice(0, 16) || 'index'

      const hashedPath = `${pathname}.${hash(path)}.${ANYTHING_ELSE_YOU_WANT_HERE_USING_HEADERS_OR_WHATEVER}`
      return hashedPath
    },
  })

  nitroApp.router.use(foundHandler.route, handler, foundHandler.method)
  console.info('installed routeRules cache handler', foundHandler.route)
})

johnjenkins avatar Sep 27 '24 14:09 johnjenkins

Does anyone have any update on this topic?

dygaomarques avatar Dec 06 '24 16:12 dygaomarques

@MiniDigger Thank you very much for your example

@lxccc812 This file would be placed inside the server/plugins folder. The defineCachedEventHandler function has an option object as second parameter where you can set a getKey function. This function will receive the event object as parameter where you can get the request URL and Query parameter as follow:

function getKey(event: Event): string {
  const currentUrl = getRequestURL(event);
  const queries = getQuery(event);
  ... your own logic
  return theKey;
}

Then you can create your own logic for creating a string key and return it inside the getKey function.

I have found a quite simple solution to just disable the generation of the Nuxt cache under certain conditions in Nuxt 3 with routeRules if this already helps you:

server/plugins/nuxt-cache-invalidator.ts

export default defineNitroPlugin(nitroApp => {
  nitroApp.hooks.hook('render:response', (response, { event }) => {
    const queries = getQuery(event);
    const shouldNotCreateCache = <HERE YOUR OWN LOGIC>

    if (shouldNotCreateCache)
      response['headers'] = {
        ...(response['headers'] ?? {}),
        'cache-control': 'no-store',
        'last-modified': 'undefined', // required so Nuxt cache is not valid and does not create a cache version
      };
    else
      response['headers'] = { ...(response['headers'] ?? {}), 'cache-control': 'max-age=300, stale-while-revalidate' };
  });
});

nuxt.config.ts

routeRules: {
    '/**': { cache: {} }, // do not set any props or values from plugin will be overwritten
  },

The workaround here to disable the cache worked beautifully, thanks!

alexh-hornby avatar Jan 22 '25 10:01 alexh-hornby