Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: move (initial) serving of cached route to event handler #73

Merged
merged 7 commits into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
49 changes: 49 additions & 0 deletions docs/advanced/route-cache-with-compression.md
Original file line number Diff line number Diff line change
@@ -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)
})
})
```

:::
4 changes: 3 additions & 1 deletion docs/features/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions playground/pages/testCompression.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<div>
<h1>This page uses the route cache together with "h3-compression".</h1>
<div id="random-number">{{ random }}</div>
</div>
</template>

<script setup lang="ts">
import { useRouteCache, useState } from '#imports'

const random = useState('compression_random_number', () => {
return Math.round(Math.random() * 100000000)
})

useRouteCache((v) => v.setCacheable().setMaxAge(1000))
</script>
10 changes: 10 additions & 0 deletions playground/server/api/testApiCompression.ts
Original file line number Diff line number Diff line change
@@ -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
})
15 changes: 15 additions & 0 deletions playground/server/plugins/compression.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
20 changes: 3 additions & 17 deletions src/runtime/helpers/routeCache.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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)
}
104 changes: 104 additions & 0 deletions src/runtime/server/handler/serveCachedRoute.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
12 changes: 12 additions & 0 deletions src/runtime/server/hooks/afterResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/hooks/beforeResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 26 additions & 7 deletions src/runtime/server/hooks/error.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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) {}
}
Loading