From 1016aab8976bd05af6172e9897f64c9783075acf Mon Sep 17 00:00:00 2001 From: Jan Hug Date: Sun, 25 Aug 2024 14:14:10 +0200 Subject: [PATCH] feature: move serving of cached route to event handler (#73) * feature: move (initial) serving of cached route to event handler * catch errors during serving from cache * write test for using route cache with compression * add doc page for using route cache with compression * also test compression on cached api handlers --- docs/.vitepress/config.ts | 4 + docs/advanced/route-cache-with-compression.md | 49 +++++ docs/features/api.md | 4 +- package-lock.json | 13 ++ package.json | 1 + playground/pages/testCompression.vue | 16 ++ playground/server/api/testApiCompression.ts | 10 + playground/server/plugins/compression.ts | 15 ++ src/runtime/helpers/routeCache.ts | 20 +- .../server/handler/serveCachedRoute.ts | 104 ++++++++++ src/runtime/server/hooks/afterResponse.ts | 12 ++ src/runtime/server/hooks/beforeResponse.ts | 2 +- src/runtime/server/hooks/error.ts | 33 ++- src/runtime/server/hooks/request.ts | 102 +--------- src/runtime/server/plugins/multiCache.ts | 32 +-- test/routeCacheWithCompression.e2e.spec.ts | 84 ++++++++ .../handler/serveCachedRoute.nuxt.spec.ts | 192 ++++++++++++++++++ .../plugin/hooks/onRequest.nuxt.spec.ts | 153 -------------- 18 files changed, 546 insertions(+), 300 deletions(-) create mode 100644 docs/advanced/route-cache-with-compression.md create mode 100644 playground/pages/testCompression.vue create mode 100644 playground/server/api/testApiCompression.ts create mode 100644 playground/server/plugins/compression.ts create mode 100644 src/runtime/server/handler/serveCachedRoute.ts create mode 100644 test/routeCacheWithCompression.e2e.spec.ts create mode 100644 test/server/handler/serveCachedRoute.nuxt.spec.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9aa4651..0207b69 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -135,6 +135,10 @@ export default defineConfig({ text: 'Using Storage Instance', link: '/advanced/storage-instance', }, + { + text: 'Route Cache with Compression', + link: '/advanced/route-cache-with-compression', + }, { text: 'Using Route Cache + CDN', link: '/advanced/route-and-cdn', diff --git a/docs/advanced/route-cache-with-compression.md b/docs/advanced/route-cache-with-compression.md new file mode 100644 index 0000000..3417ba8 --- /dev/null +++ b/docs/advanced/route-cache-with-compression.md @@ -0,0 +1,49 @@ +# Using Route Cache with Compression + +It is possible to use the route cache together with compression, such as with +the [h3-compression](https://github.com/CodeDredd/h3-compression) library. You +may compress both Nuxt-rendered pages or responses from server handlers. + +However, due to the way that both this module and the `h3-compression` library +work, you can not use compression **within the event handler**, for the simple +reason that your event handler is only called once when the response is stored +in cache. Afterwards the cached response is returned immediately. Of course you +can still continue to use compression in an event handler, but just not together +with the route cache. + +For this reason, you have to compress responses globally, via the +[`beforeResponse` Nitro hook](https://nitro.unjs.io/guide/plugins#available-hooks). +This is the only hook that is guaranteed to work; using `render:response` **will +not** work, because this hook is only called on the first render of the page. + +::: info + +While you can use compression from within your app like that, an alternative +approach would be to handle this directly on your web server, using +[mod_deflate for Apache](https://httpd.apache.org/docs/current/mod/mod_deflate.html) +or by setting +[`gzip on` in nginx](https://docs.nginx.com/nginx/admin-guide/web-server/compression/). + +::: + +## Example + +::: code-group + +```typescript [./server/plugins/compression.ts] +import { useCompression } from 'h3-compression' +import { defineNitroPlugin } from 'nitropack/runtime' + +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook('beforeResponse', async (event, response) => { + // Prevent some paths from being compressed. + if (event.path.startsWith('/no-compression')) { + return + } + + await useCompression(event, response) + }) +}) +``` + +::: diff --git a/docs/features/api.md b/docs/features/api.md index a4fe154..894e5f5 100644 --- a/docs/features/api.md +++ b/docs/features/api.md @@ -202,7 +202,9 @@ items are purged. This is because cache tags are stored together with the items. This means that every item needs to be loaded from the cache and its tags checked. -The delay is configurable via the `api.cacheTagInvalidationDelay` option. ::: +The delay is configurable via the `api.cacheTagInvalidationDelay` option. + +::: ### Example Purge all cache items with cache tag `language:de` diff --git a/package-lock.json b/package-lock.json index 882efbe..c02348a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-vue": "^9.26.0", + "h3-compression": "^0.3.2", "happy-dom": "^14.12.0", "nuxt": "^3.12.2", "playwright-core": "^1.44.1", @@ -9508,6 +9509,18 @@ "unenv": "^1.9.0" } }, + "node_modules/h3-compression": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/h3-compression/-/h3-compression-0.3.2.tgz", + "integrity": "sha512-B+yCKyDRnO0BXSfjAP4tCXJgJwmnKp3GyH5Yh66mY9KuOCrrGQSPk/gBFG2TgH7OyB/6mvqNZ1X0XNVuy0qRsw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/codedredd" + }, + "peerDependencies": { + "h3": "^1.6.0" + } + }, "node_modules/happy-dom": { "version": "14.12.0", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-14.12.0.tgz", diff --git a/package.json b/package.json index 701510d..d99c574 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-vue": "^9.26.0", + "h3-compression": "^0.3.2", "happy-dom": "^14.12.0", "nuxt": "^3.12.2", "playwright-core": "^1.44.1", diff --git a/playground/pages/testCompression.vue b/playground/pages/testCompression.vue new file mode 100644 index 0000000..95a5443 --- /dev/null +++ b/playground/pages/testCompression.vue @@ -0,0 +1,16 @@ + + + diff --git a/playground/server/api/testApiCompression.ts b/playground/server/api/testApiCompression.ts new file mode 100644 index 0000000..bb2df83 --- /dev/null +++ b/playground/server/api/testApiCompression.ts @@ -0,0 +1,10 @@ +import { defineEventHandler } from 'h3' +import { useRouteCache } from '#nuxt-multi-cache/composables' + +export default defineEventHandler((event) => { + useRouteCache((helper) => { + helper.setCacheable().setMaxAge(234234) + }, event) + const number = Math.round(Math.random() * 1000000000) + return 'This is a compressed API response: ' + number +}) diff --git a/playground/server/plugins/compression.ts b/playground/server/plugins/compression.ts new file mode 100644 index 0000000..2e9212c --- /dev/null +++ b/playground/server/plugins/compression.ts @@ -0,0 +1,15 @@ +import { useCompression } from 'h3-compression' +import { defineNitroPlugin } from 'nitropack/runtime' + +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook('beforeResponse', async (event, response) => { + if ( + !event.path.startsWith('/testCompression') && + !event.path.startsWith('/api/testApiCompression') + ) { + return + } + + await useCompression(event, response) + }) +}) diff --git a/src/runtime/helpers/routeCache.ts b/src/runtime/helpers/routeCache.ts index 4ff6290..5365782 100644 --- a/src/runtime/helpers/routeCache.ts +++ b/src/runtime/helpers/routeCache.ts @@ -1,10 +1,7 @@ import { setResponseHeaders, setResponseStatus, type H3Event } from 'h3' import { RouteCacheItem } from '../types' -export async function serveCachedRoute( - event: H3Event, - decoded: RouteCacheItem, -) { +export function setCachedResponse(event: H3Event, decoded: RouteCacheItem) { // Set the cached headers. The name suggests otherwise, but this appends // headers (e.g. does not override existing headers.) if (decoded.headers) { @@ -16,18 +13,7 @@ export async function serveCachedRoute( setResponseStatus(event, decoded.statusCode) } - const response = new Response(decoded.data) - - Object.entries(decoded.headers).forEach(([name, value]) => { - response.headers.set(name, value) - }) - - // 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. + // Maked sure that code that runs afterwards does not store the same + // cached response again in the cache. event.__MULTI_CACHE_SERVED_FROM_CACHE = true - event._handled = true - - await event.respondWith(response) } diff --git a/src/runtime/server/handler/serveCachedRoute.ts b/src/runtime/server/handler/serveCachedRoute.ts new file mode 100644 index 0000000..94e6ee7 --- /dev/null +++ b/src/runtime/server/handler/serveCachedRoute.ts @@ -0,0 +1,104 @@ +import { type H3Event } from 'h3' +import { useMultiCacheApp } from '../utils/useMultiCacheApp' +import { + encodeRouteCacheKey, + getCacheKeyWithPrefix, + getMultiCacheContext, +} from '../../helpers/server' +import { + decodeRouteCacheItem, + handleRawCacheData, +} from '../../helpers/cacheItem' +import { RouteCacheItem } from '../../types' +import { MultiCacheState } from '../../helpers/MultiCacheState' +import { logger } from '../../helpers/logger' +import { setCachedResponse } from '../../helpers/routeCache' +import { useRuntimeConfig } from '#imports' + +function canBeServedFromCache( + key: string, + decoded: RouteCacheItem, + state: MultiCacheState, +): boolean { + const now = Date.now() / 1000 + const isExpired = decoded.expires ? now >= decoded.expires : false + + // Item is not expired, so we can serve it. + if (!isExpired) { + return true + } + + // The route may be served stale while revalidating if it currently is being + // revalidated. + if (decoded.staleWhileRevalidate && state.isBeingRevalidated(key)) { + return true + } + + // Is both expired and not eligible to be served stale while revalidating. + return false +} + +export async function serveCachedHandler(event: H3Event) { + try { + const { serverOptions, state } = useMultiCacheApp() + const context = getMultiCacheContext(event) + + if (!context?.route) { + return + } + + // Build the cache key. + const fullKey = serverOptions?.route?.buildCacheKey + ? serverOptions.route.buildCacheKey(event) + : getCacheKeyWithPrefix(encodeRouteCacheKey(event.path), event) + + // Check if there is a cache entry for this key. + const cachedRaw = handleRawCacheData( + await context.route.getItemRaw(fullKey), + ) + + // No cache entry. + if (!cachedRaw) { + return + } + const decoded = decodeRouteCacheItem(cachedRaw) + + // Decoding failed. May happen if the format is wrong, possibly after a + // deployment with a newer version. + if (!decoded) { + return + } + + // Store the decoded cache item in the event context. + event.__MULTI_CACHE_DECODED_CACHED_ROUTE = decoded + + // Check if item can be served from cache. + if (!canBeServedFromCache(fullKey, decoded, state)) { + // Mark the key as being revalidated. + if (decoded.staleWhileRevalidate) { + state.addKeyBeingRevalidated(fullKey) + event.__MULTI_CACHE_REVALIDATION_KEY = fullKey + } + + // Returning, so the route is revalidated. + return + } + + const debugEnabled = useRuntimeConfig().multiCache.debug + + if (debugEnabled) { + logger.info('Serving cached route for path: ' + event.path, { + fullKey, + }) + } + + setCachedResponse(event, decoded) + + return decoded.data + } catch (e) { + if (e instanceof Error) { + // eslint-disable-next-line no-console + console.debug(e.message) + } + } +} diff --git a/src/runtime/server/hooks/afterResponse.ts b/src/runtime/server/hooks/afterResponse.ts index 339323a..94466d5 100644 --- a/src/runtime/server/hooks/afterResponse.ts +++ b/src/runtime/server/hooks/afterResponse.ts @@ -42,6 +42,11 @@ export async function onAfterResponse( event: H3Event, response: { body?: unknown } | undefined, ) { + // Has already been served from cache, so there is nothing to do here. + if (event.__MULTI_CACHE_SERVED_FROM_CACHE) { + return + } + if (!response?.body) { return } @@ -72,6 +77,13 @@ export async function onAfterResponse( let responseHeaders = getResponseHeaders(event) + // We have to remove this header, because what we store in the cache is not + // encoded. Apps may implement custom encoding that is applied in the + // beforeResponse hook. However, it is not guaranteed that when serving a + // cached route the same compression is also being applied again. If we were + // to always send this header, then the response might be invalid. + responseHeaders['content-encoding'] = undefined + if (serverOptions.route?.alterCachedHeaders) { responseHeaders = serverOptions.route.alterCachedHeaders(responseHeaders) } diff --git a/src/runtime/server/hooks/beforeResponse.ts b/src/runtime/server/hooks/beforeResponse.ts index f0735ed..d048cca 100644 --- a/src/runtime/server/hooks/beforeResponse.ts +++ b/src/runtime/server/hooks/beforeResponse.ts @@ -30,7 +30,7 @@ function handleCDN(app: MultiCacheApp, event: H3Event) { * * This is called after a valid response was built, but before it is sent. */ -export function onBeforeResponse(event: H3Event) { +export function onBeforeResponse(event: H3Event, response: { body?: unknown }) { const app = useMultiCacheApp() handleCDN(app, event) diff --git a/src/runtime/server/hooks/error.ts b/src/runtime/server/hooks/error.ts index 9b20980..9b22465 100644 --- a/src/runtime/server/hooks/error.ts +++ b/src/runtime/server/hooks/error.ts @@ -1,17 +1,26 @@ -import type { CapturedErrorContext } from 'nitropack/types' -import { serveCachedRoute } from '../../helpers/routeCache' +import type { CapturedErrorContext } from 'nitropack' +import { setCachedResponse } from '../../helpers/routeCache' +import { useMultiCacheApp } from '../utils/useMultiCacheApp' /** - * Callback for the 'beforeResponse' nitro hook. + * Callback for the 'error' nitro hook. * - * This is called after a valid response was built, but before it is sent. + * This is called during any error that happens in an event handler. */ -export async function onError(_error: Error, ctx: CapturedErrorContext) { +export function onError(_error: Error, ctx: CapturedErrorContext) { try { if (!ctx.event) { return } - // Get the decoded route cache item. The "request" handler may have already fetched this, so we can reuse it. + + const { state } = useMultiCacheApp() + + if (ctx.event.__MULTI_CACHE_REVALIDATION_KEY) { + state.removeKeyBeingRevalidated(ctx.event.__MULTI_CACHE_REVALIDATION_KEY) + } + + // Get the decoded route cache item. The "request" handler may have already + // fetched this, so we can reuse it. const decoded = ctx.event.__MULTI_CACHE_DECODED_CACHED_ROUTE if (!decoded) { @@ -23,11 +32,21 @@ export async function onError(_error: Error, ctx: CapturedErrorContext) { return } + // If we reached the expiry date, return. const now = Date.now() / 1000 if (now >= decoded.staleIfErrorExpires) { return } - await serveCachedRoute(ctx.event, decoded) + setCachedResponse(ctx.event, decoded) + + const response = new Response(decoded.data, { + headers: decoded.headers, + }) + + // Directly respond with our response. + // This might potentially lead to other hooks (such as beforeResponse) not + // being called here that would for example compress the response. + return ctx.event.respondWith(response) } catch (_e) {} } diff --git a/src/runtime/server/hooks/request.ts b/src/runtime/server/hooks/request.ts index 4fe4f5e..0a6a883 100644 --- a/src/runtime/server/hooks/request.ts +++ b/src/runtime/server/hooks/request.ts @@ -1,24 +1,14 @@ import { type H3Event } from 'h3' -import type { NuxtMultiCacheSSRContext, RouteCacheItem } from '../../types' +import type { NuxtMultiCacheSSRContext } from '../../types' import { MULTI_CACHE_CDN_CONTEXT_KEY, MULTI_CACHE_CONTEXT_KEY, MULTI_CACHE_PREFIX_KEY, MULTI_CACHE_ROUTE_CONTEXT_KEY, - encodeRouteCacheKey, - getCacheKeyWithPrefix, } from '../../helpers/server' import { NuxtMultiCacheRouteCacheHelper } from '../../helpers/RouteCacheHelper' -import { - decodeRouteCacheItem, - handleRawCacheData, -} from '../../helpers/cacheItem' -import { logger } from '../../helpers/logger' import { useMultiCacheApp } from '../utils/useMultiCacheApp' import { NuxtMultiCacheCDNHelper } from '../../helpers/CDNHelper' -import { serveCachedRoute } from '../../helpers/routeCache' -import type { MultiCacheState } from '../../helpers/MultiCacheState' -import { useRuntimeConfig } from '#imports' /** * Add the cache context singleton to the current request. @@ -92,29 +82,6 @@ function applies(path: string): boolean { return !/.\.(ico|png|jpg|js|css|html|woff|woff2|ttf|otf|eot|svg)$/.test(path) } -function canBeServedFromCache( - key: string, - decoded: RouteCacheItem, - state: MultiCacheState, -): boolean { - const now = Date.now() / 1000 - const isExpired = decoded.expires ? now >= decoded.expires : false - - // Item is not expired, so we can serve it. - if (!isExpired) { - return true - } - - // The route may be served stale while revalidating and it is not currently - // being revalidated. - if (decoded.staleWhileRevalidate && state.isBeingRevalidated(key)) { - return true - } - - // Is both expired and not eligible to be served stale while revalidating. - return false -} - /** * Callback for the 'request' nitro hook. * @@ -140,70 +107,5 @@ export async function onRequest(event: H3Event) { } // Add the cache context. - const multiCache = await addCacheContext(event) - - // Route caching is not enabled, so we can return now. - if (!multiCache?.route) { - return - } - - try { - const { serverOptions, state } = useMultiCacheApp() - - // Build the cache key. - const fullKey = serverOptions?.route?.buildCacheKey - ? serverOptions.route.buildCacheKey(event) - : getCacheKeyWithPrefix(encodeRouteCacheKey(event.path), event) - - // Check if there is a cache entry for this key. - const cachedRaw = handleRawCacheData( - await multiCache.route.getItemRaw(fullKey), - ) - - // No cache entry. - if (!cachedRaw) { - return - } - - const decoded = decodeRouteCacheItem(cachedRaw) - - // Decoding failed. May happen if the format is wrong, possibly after a - // deployment with a newer version. - if (!decoded) { - return - } - - // Check if item can be served from cache. - if (!canBeServedFromCache(fullKey, decoded, state)) { - // Mark the key as being revalidated. - if (decoded.staleWhileRevalidate) { - state.addKeyBeingRevalidated(fullKey) - event.__MULTI_CACHE_REVALIDATION_KEY = fullKey - } - - if (decoded.staleIfErrorExpires) { - // Store the decoded cache item in the event context. - // May be used by the error hook handler to serve a stale route on error. - event.__MULTI_CACHE_DECODED_CACHED_ROUTE = decoded - } - - // Returning, so the route is revalidated. - return - } - - const debugEnabled = useRuntimeConfig().multiCache.debug - - if (debugEnabled) { - logger.info('Serving cached route for path: ' + event.path, { - fullKey, - }) - } - - await serveCachedRoute(event, decoded) - } catch (e) { - if (e instanceof Error) { - // eslint-disable-next-line no-console - console.debug(e.message) - } - } + await addCacheContext(event) } diff --git a/src/runtime/server/plugins/multiCache.ts b/src/runtime/server/plugins/multiCache.ts index 2e5384e..a67bbb2 100644 --- a/src/runtime/server/plugins/multiCache.ts +++ b/src/runtime/server/plugins/multiCache.ts @@ -6,8 +6,9 @@ import { onAfterResponse } from '../hooks/afterResponse' import type { MultiCacheApp, NuxtMultiCacheSSRContext } from '../../types' import { onError } from '../hooks/error' import { MultiCacheState } from '../../helpers/MultiCacheState' -import { useRuntimeConfig } from '#imports' +import { serveCachedHandler } from '../handler/serveCachedRoute' import { serverOptions } from '#multi-cache-server-options' +import { useRuntimeConfig } from '#imports' function createMultiCacheApp(): MultiCacheApp { const runtimeConfig = useRuntimeConfig() @@ -46,28 +47,17 @@ 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) { + // Add the handler that may serve cached routes. + // We have to make sure that this handler is the very first in the array. + // Using "unshift" is our only option here, but there is no guarantee that + // during runtime our handler is actually executed first. + nitroApp.h3App.stack.unshift({ + route: '/', + handler: serveCachedHandler, + }) + // Hook into afterResponse to store cacheable responses in cache. nitroApp.hooks.hook('afterResponse', onAfterResponse) diff --git a/test/routeCacheWithCompression.e2e.spec.ts b/test/routeCacheWithCompression.e2e.spec.ts new file mode 100644 index 0000000..46f52b7 --- /dev/null +++ b/test/routeCacheWithCompression.e2e.spec.ts @@ -0,0 +1,84 @@ +import path from 'node:path' +import { setup, fetch } from '@nuxt/test-utils/e2e' +import { describe, expect, test } from 'vitest' +import type { NuxtMultiCacheOptions } from '../src/runtime/types' +import purgeAll from './__helpers__/purgeAll' + +const multiCache: NuxtMultiCacheOptions = { + component: { + enabled: false, + }, + data: { + enabled: false, + }, + route: { + enabled: true, + }, + cdn: { + enabled: false, + }, + 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, +}) + +async function testResponse(path: string) { + // First call puts it into cache. + const first = await fetch(path, { + method: 'get', + headers: { + 'accept-encoding': 'br', + }, + }) + + // Get the encoding. + const firstEncoding = first.headers.get('content-encoding') + + // Test that the compression feature has actually compressed the response. + expect(firstEncoding).toEqual('br') + + // Second call should get it from cache. + const second = await fetch(path, { + method: 'get', + headers: { + 'accept-encoding': 'br', + }, + }) + + const responseFirst = await first.text() + const responseSecond = await second.text() + + // Response should be identical (contains a random number). + expect(responseFirst).toEqual(responseSecond) + + const secondEncoding = second.headers.get('content-encoding') + + // The encoding should be the same, because the compression feature was + // able to compress the cached response again. + expect(secondEncoding).toEqual(firstEncoding) +} + +describe('The route cache with compression enabled', () => { + test('caches a page', async () => { + await purgeAll() + await testResponse('/testCompression') + }) + + test('caches the result of an API handler', async () => { + await purgeAll() + await testResponse('/api/testApiCompression') + }) +}) diff --git a/test/server/handler/serveCachedRoute.nuxt.spec.ts b/test/server/handler/serveCachedRoute.nuxt.spec.ts new file mode 100644 index 0000000..97e226c --- /dev/null +++ b/test/server/handler/serveCachedRoute.nuxt.spec.ts @@ -0,0 +1,192 @@ +import { describe, expect, test, vi } from 'vitest' +import { mockNuxtImport } from '@nuxt/test-utils/runtime' +import { encodeRouteCacheItem } from '../../../dist/runtime/helpers/cacheItem' +import { serveCachedHandler } from '../../../src/runtime/server/handler/serveCachedRoute' +import { MULTI_CACHE_CONTEXT_KEY } from '../../../src/runtime/helpers/server' + +mockNuxtImport('useRuntimeConfig', () => { + return () => { + return { + multiCache: { + cdn: { + cacheTagHeader: 'Cache-Tag', + cacheControlHeader: 'Surrogate-Control', + }, + }, + } + } +}) + +vi.mock('#multi-cache-server-options', () => { + return { + serverOptions: {}, + } +}) + +const mocks = vi.hoisted(() => { + return { + useNitroApp: vi.fn(), + } +}) + +vi.mock('nitropack/runtime', () => { + return { + useNitroApp: mocks.useNitroApp, + } +}) + +describe('serveCachedRoute event handler', () => { + test('Gets a route from cache.', async () => { + mocks.useNitroApp.mockReturnValue({ + multiCache: { + cache: {}, + serverOptions: {}, + config: { + cdn: {}, + }, + }, + }) + + const setHeaders: Record = {} + const event = { + path: '/', + headers: {}, + node: { + res: { + setHeader: function (name: string, value: string) { + setHeaders[name] = value + }, + statusCode: null, + }, + }, + context: {}, + + [MULTI_CACHE_CONTEXT_KEY]: { + route: { + getItemRaw() { + return Promise.resolve( + encodeRouteCacheItem( + '', + { + 'x-custom-header': 'test', + }, + 200, + undefined, + undefined, + undefined, + [], + ), + ) + }, + }, + }, + } + + const result = await serveCachedHandler(event as any) + + expect(result).toMatchInlineSnapshot(`""`) + + expect(event.node.res.statusCode).toMatchInlineSnapshot(`200`) + expect(setHeaders).toMatchInlineSnapshot(` + { + "x-custom-header": "test", + } + `) + mocks.useNitroApp.mockRestore() + }) + + test('Respects max age of a cached route', async () => { + const date = new Date(2022, 11, 29, 13, 0) + vi.setSystemTime(date) + + mocks.useNitroApp.mockReturnValue({ + multiCache: { + cache: {}, + serverOptions: {}, + config: { + cdn: {}, + }, + }, + }) + + const setHeaders: Record = {} + const event = { + path: '/', + headers: {}, + node: { + res: { + setHeader: function (name: string, value: string) { + setHeaders[name] = value + }, + }, + }, + context: {}, + response: null as Response | null, + respondWith: function (res: any) { + this.response = res + }, + [MULTI_CACHE_CONTEXT_KEY]: { + route: { + getItemRaw() { + return Promise.resolve( + encodeRouteCacheItem( + '', + { + 'x-custom-header': 'test', + }, + 200, + (date.getTime() - 3000) / 1000, + undefined, + undefined, + [], + ), + ) + }, + }, + }, + } + + // Should not serve from cache because item is stale. + expect(await serveCachedHandler(event as any)).toBeUndefined() + + // Now set time to one year ago. + vi.setSystemTime(new Date(date).setFullYear(2021)) + + const result = await serveCachedHandler(event as any) + + // Is now served from cache because it's not stale anymore. + expect(result).toMatchInlineSnapshot(`""`) + mocks.useNitroApp.mockRestore() + }) + + test('Catches errors happening when loading item from cache.', async () => { + const consoleSpy = vi.spyOn(global.console, 'debug') + mocks.useNitroApp.mockReturnValue({ + multiCache: { + cache: {}, + serverOptions: {}, + config: { + cdn: {}, + }, + }, + }) + + const event = { + path: '/', + node: {}, + context: {}, + [MULTI_CACHE_CONTEXT_KEY]: { + route: { + getItemRaw() { + throw new Error('Failed to get item from cache.') + }, + }, + }, + } + + await serveCachedHandler(event as any) + + expect(consoleSpy).toHaveBeenCalledWith('Failed to get item from cache.') + mocks.useNitroApp.mockRestore() + }) +}) diff --git a/test/server/plugin/hooks/onRequest.nuxt.spec.ts b/test/server/plugin/hooks/onRequest.nuxt.spec.ts index c8fe05d..6cc77d3 100644 --- a/test/server/plugin/hooks/onRequest.nuxt.spec.ts +++ b/test/server/plugin/hooks/onRequest.nuxt.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, test, vi } from 'vitest' import { mockNuxtImport } from '@nuxt/test-utils/runtime' import { onRequest } from '../../../../dist/runtime/server/hooks/request' -import { encodeRouteCacheItem } from '../../../../dist/runtime/helpers/cacheItem' mockNuxtImport('useRuntimeConfig', () => { return () => { @@ -117,156 +116,4 @@ describe('onRequest nitro hook handler', () => { mocks.useNitroApp.mockRestore() }) - - test('Gets a route from cache.', async () => { - mocks.useNitroApp.mockReturnValue({ - multiCache: { - cache: { - route: { - getItemRaw() { - return Promise.resolve( - encodeRouteCacheItem( - '', - { - 'x-custom-header': 'test', - }, - 200, - undefined, - [], - ), - ) - }, - }, - }, - serverOptions: {}, - config: { - cdn: {}, - }, - }, - }) - - const setHeaders: Record = {} - const event = { - path: '/', - headers: {}, - node: { - res: { - setHeader: function (name: string, value: string) { - setHeaders[name] = value - }, - }, - }, - context: {}, - response: null as Response | null, - respondWith: function (res: any) { - this.response = res - }, - } - - await onRequest(event as any) - - expect(event.response).toBeTruthy() - expect(await event.response?.text()).toMatchInlineSnapshot( - `""`, - ) - - expect(event.response?.status).toMatchInlineSnapshot(`200`) - expect(setHeaders).toMatchInlineSnapshot(` - { - "x-custom-header": "test", - } - `) - mocks.useNitroApp.mockRestore() - }) - - test('Respects max age of a cached route', async () => { - const date = new Date(2022, 11, 29, 13, 0) - vi.setSystemTime(date) - - mocks.useNitroApp.mockReturnValue({ - multiCache: { - cache: { - route: { - getItemRaw() { - return Promise.resolve( - encodeRouteCacheItem( - '', - { - 'x-custom-header': 'test', - }, - 200, - (date.getTime() - 3000) / 1000, - [], - ), - ) - }, - }, - }, - serverOptions: {}, - config: { - cdn: {}, - }, - }, - }) - - const setHeaders: Record = {} - const event = { - path: '/', - headers: {}, - node: { - res: { - setHeader: function (name: string, value: string) { - setHeaders[name] = value - }, - }, - }, - context: {}, - response: null as Response | null, - respondWith: function (res: any) { - this.response = res - }, - } - - // Should not serve from cache because item is stale. - expect(await onRequest(event as any)).toBeUndefined() - - // Now set time to one year ago. - vi.setSystemTime(new Date(date).setFullYear(2021)) - - await onRequest(event as any) - - // Is now served from cache because it's not stale anymore. - expect(event.response).toBeTruthy() - mocks.useNitroApp.mockRestore() - }) - - test('Catches errors happening when loading item from cache.', async () => { - const consoleSpy = vi.spyOn(global.console, 'debug') - mocks.useNitroApp.mockReturnValue({ - multiCache: { - cache: { - route: { - getItemRaw() { - throw new Error('Failed to get item from cache.') - }, - }, - }, - serverOptions: {}, - config: { - cdn: {}, - }, - }, - }) - - const event = { - path: '/', - node: {}, - context: {}, - } - - await onRequest(event as any) - - expect(consoleSpy).toHaveBeenCalledWith('Failed to get item from cache.') - mocks.useNitroApp.mockRestore() - }) })