diff --git a/package-lock.json b/package-lock.json index 73a53ac..882efbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "nuxt-multi-cache", - "version": "3.3.0", + "version": "3.3.1", "license": "MIT", "dependencies": { "@nuxt/kit": "^3.12.2", diff --git a/package.json b/package.json index d74484a..701510d 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "dev:build": "nuxi build playground", "dev:serve": "node playground/.output/server/index.mjs", "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground && nuxi prepare playground-disk", + "dev:inspect": "nuxi dev playground --inspect", "typecheck": "nuxi typecheck", "docs:dev": "vitepress dev docs --port 5000", "docs:build": "vitepress build docs", diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 43521c7..3c48023 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -6,6 +6,16 @@ export default defineNuxtConfig({ '/spaPageWithCachedComponent': { ssr: false }, '/spaDataCache': { ssr: false }, '/spaPageWithException': { ssr: false }, + '/api/routeCacheWithRouteRules': { + headers: { + 'x-route-rules-header': 'Set via routeRules', + }, + }, + '/api/testStaleIfError': { + headers: { + 'x-route-rules-header': 'Set via routeRules', + }, + }, }, modules: [NuxtMultiCache], imports: { diff --git a/playground/server/api/routeCacheWithRouteRules.ts b/playground/server/api/routeCacheWithRouteRules.ts new file mode 100644 index 0000000..1de6dc4 --- /dev/null +++ b/playground/server/api/routeCacheWithRouteRules.ts @@ -0,0 +1,11 @@ +import { defineEventHandler } from 'h3' +import { useRouteCache } from '#nuxt-multi-cache/composables' + +export default defineEventHandler((event) => { + useRouteCache((helper) => { + helper.setCacheable().setMaxAge(234234) + }, event) + return { + data: 'There are route rules defined for this endpoint.', + } +}) diff --git a/src/runtime/helpers/routeCache.ts b/src/runtime/helpers/routeCache.ts index 29a9731..4ff6290 100644 --- a/src/runtime/helpers/routeCache.ts +++ b/src/runtime/helpers/routeCache.ts @@ -22,6 +22,12 @@ export async function serveCachedRoute( response.headers.set(name, value) }) - // Respond with the cached response. + // We use this to tell our "fake" event handler that runs as the very first + // one in the stack to return a fake response (which is not actually returned + // to the client). It just tells H3 to stop executing any other event + // handlers. + event.__MULTI_CACHE_SERVED_FROM_CACHE = true + event._handled = true + await event.respondWith(response) } diff --git a/src/runtime/server/hooks/error.ts b/src/runtime/server/hooks/error.ts index 4cccba4..9b20980 100644 --- a/src/runtime/server/hooks/error.ts +++ b/src/runtime/server/hooks/error.ts @@ -6,7 +6,7 @@ import { serveCachedRoute } from '../../helpers/routeCache' * * This is called after a valid response was built, but before it is sent. */ -export function onError(_error: Error, ctx: CapturedErrorContext) { +export async function onError(_error: Error, ctx: CapturedErrorContext) { try { if (!ctx.event) { return @@ -28,6 +28,6 @@ export function onError(_error: Error, ctx: CapturedErrorContext) { return } - serveCachedRoute(ctx.event, decoded) + await serveCachedRoute(ctx.event, decoded) } catch (_e) {} } diff --git a/src/runtime/server/plugins/multiCache.ts b/src/runtime/server/plugins/multiCache.ts index 8f04b6e..2e5384e 100644 --- a/src/runtime/server/plugins/multiCache.ts +++ b/src/runtime/server/plugins/multiCache.ts @@ -46,6 +46,26 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('beforeResponse', onBeforeResponse) } + // We have to add a "fake" event handler as the very first handler in the + // stack. Since we serve from cache inside the "request" hook (which runs + // before any event handlers), the response has already been sent at that + // point. However, H3 will still continue to execute the event handlers + // and one of them is the "route-rules" handler from Nitro which may set + // response headers, which will throw an "Cannot set headers after they are + // sent to the client" error. + nitroApp.h3App.stack.unshift({ + route: '/', + handler: function (event) { + // This is set by our serveCachedRoute method. + if (event.__MULTI_CACHE_SERVED_FROM_CACHE) { + // This is never actually sent to the client. It's just a workaround + // (or more a hack) to tell H3 to stop executing any other event + // handlers. + return 'NOOP' + } + }, + }) + // Only needed if route caching is enabled. if (multiCache.config.route) { // Hook into afterResponse to store cacheable responses in cache. diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 0bc5a65..9b2c5f5 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -337,5 +337,10 @@ declare module 'h3' { * The route cache key that is currently being revalidated. */ __MULTI_CACHE_REVALIDATION_KEY?: string + + /** + * Whether the current request has already been served from cache. + */ + __MULTI_CACHE_SERVED_FROM_CACHE?: boolean } } diff --git a/test/routeRulesHeaders.e2e.spec.ts b/test/routeRulesHeaders.e2e.spec.ts new file mode 100644 index 0000000..7ca04b9 --- /dev/null +++ b/test/routeRulesHeaders.e2e.spec.ts @@ -0,0 +1,90 @@ +import path from 'path' +import { setup, fetch } from '@nuxt/test-utils/e2e' +import { describe, expect, test } from 'vitest' +import type { NuxtMultiCacheOptions } from '../src/runtime/types' +import { decodeRouteCacheItem } from '../src/runtime/helpers/cacheItem' + +const multiCache: NuxtMultiCacheOptions = { + route: { + enabled: true, + }, + api: { + enabled: true, + authorization: false, + cacheTagInvalidationDelay: 5000, + }, +} +const nuxtConfig: any = { + multiCache, +} +await setup({ + server: true, + logLevel: 0, + runner: 'vitest', + build: true, + rootDir: path.resolve(__dirname, './../playground'), + nuxtConfig, +}) + +/** + * This test checks that our "fake event handler" that prevents Nitro from + * applying route rules on cached routes works correctly. + */ +describe('Route cache in combination with routeRules', () => { + test('caches headers set by routeRules', async () => { + const HEADER = 'Set via routeRules' + + // First request should put it in cache. + const responseA = await fetch('/api/routeCacheWithRouteRules') + + // The route rules have been applied. + expect(responseA.headers.get('x-route-rules-header')).toEqual(HEADER) + + // Get the cached item. + const stats = await fetch('/__nuxt_multi_cache/stats/route', { + method: 'get', + headers: { + 'x-nuxt-multi-cache-token': 'hunter2', + }, + }).then((v) => v.json()) + + const cacheItem = stats.rows[0].data + const decoded = decodeRouteCacheItem(cacheItem) + + // The routeRules header should be stored in the cache. + expect(decoded?.headers['x-route-rules-header']).toEqual(HEADER) + + // Second request comes from cache. + const responseB = await fetch('/api/routeCacheWithRouteRules') + + // The headers are not applied from route rules anymore. + // Instead, because headers were cached originally, they are applied + // to the response. + expect(responseB.headers.get('x-route-rules-header')).toEqual(HEADER) + }) + + // During "stale if error", the event handlers are executed as normal, + // including the "route rules" handler by Nitro. In this scenario, the route + // cache serves the cached route from the "error" hook, which runs *after* + // the route rules handler. This is why we don't have the problem anymore + // with "Cannot set headers after they are sent to the client". + // This test makes sure that route rules are also applied in this scenario. + test('caches headers set by routeRules on "stale if error"', async () => { + const HEADER = 'Set via routeRules' + + // First request should put it in cache. + const responseA = await fetch('/api/testStaleIfError') + + // The route rules have been applied. + expect(responseA.headers.get('x-route-rules-header')).toEqual(HEADER) + + // Second request will throw an error and then serve it from cache. + const responseB = await fetch('/api/testStaleIfError', { + headers: { + 'x-nuxt-throw-error': 'true', + }, + }) + + expect(responseB.headers.get('x-route-rules-header')).toEqual(HEADER) + }) +})