Skip to content

Commit 1204b40

Browse files
authored
feat: support cache expiration & improve admin speed (#313)
1 parent 406fb95 commit 1204b40

File tree

15 files changed

+170
-56
lines changed

15 files changed

+170
-56
lines changed

docs/content/1.docs/2.features/cache.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,18 @@ It is important to note that the `event` argument should always be the first arg
7979
[Read more about this in the Nitro docs](https://nitro.unjs.io/guide/cache#edge-workers).
8080
::
8181

82-
## Cache Keys and Invalidation
82+
## Cache Invalidation
8383

8484
When using the `defineCachedFunction` or `defineCachedEventHandler` functions, the cache key is generated using the following pattern:
8585

8686
```ts
8787
`${options.group}:${options.name}:${options.getKey(...args)}.json`
8888
```
8989

90+
The defaults are:
91+
- `group`: `'nitro'`
92+
- `name`: `'handlers'` for api routes and `'functions'` for server functions
93+
9094
For example, the following function:
9195

9296
```ts
@@ -114,3 +118,11 @@ await useStorage('cache').removeItem('nitro:functions:getAccessToken:default.jso
114118
::note{to="https://nitro.unjs.io/guide/cache"}
115119
Read more about Nitro Cache.
116120
::
121+
122+
## Cache Expiration
123+
124+
As NuxtHub leverages Cloudflare Workers KV to store your cache entries, we leverage the [`expiration` property](https://developers.cloudflare.com/kv/api/write-key-value-pairs/#expiring-keys) of the KV binding to handle the cache expiration.
125+
126+
::note
127+
If you set an expiration (`maxAge`) lower than `60` seconds, NuxtHub will set the KV entry expiration to `60` seconds in the future (Cloudflare KV limitation) so it can be removed automatically.
128+
::

playground/server/api/cached.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
export default cachedEventHandler(async () => {
1+
const test = defineCachedFunction((_event) => {
2+
return 'test'
3+
}, {
4+
getKey: () => 'test'
5+
})
6+
7+
export default cachedEventHandler(async (event) => {
28
return {
3-
now: Date.now()
9+
now: Date.now(),
10+
test: test(event)
411
}
512
}, {
6-
maxAge: 10
13+
maxAge: 10,
14+
swr: true
715
})

playground/server/api/cf.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export default eventHandler(async () => {
2+
const event = useEvent()
3+
4+
const { isEUCountry, continent, city, timezone, country, region, latitude, longitude, botManagement } = event.context.cf
5+
const ip = getHeader(event, 'cf-connecting-ip') || getRequestIP(event, { xForwardedFor: true })
6+
return {
7+
continent,
8+
isEUCountry,
9+
country,
10+
region,
11+
city,
12+
timezone,
13+
latitude,
14+
longitude,
15+
botManagement,
16+
ip
17+
}
18+
})

src/features.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import { execSync } from 'node:child_process'
22
import type { Nuxt } from '@nuxt/schema'
33
import { logger, addImportsDir, addServerImportsDir, addServerScanDir, createResolver } from '@nuxt/kit'
44
import { joinURL } from 'ufo'
5-
import { join } from 'pathe'
65
import { defu } from 'defu'
76
import { $fetch } from 'ofetch'
87
import { addDevToolsCustomTabs } from './utils/devtools'
98

109
const log = logger.withTag('nuxt:hub')
11-
const { resolve } = createResolver(import.meta.url)
10+
const { resolve, resolvePath } = createResolver(import.meta.url)
1211

1312
export interface HubConfig {
1413
remote: string | boolean
@@ -149,20 +148,20 @@ export async function setupBrowser(nuxt: Nuxt) {
149148
addServerImportsDir(resolve('./runtime/browser/server/utils'))
150149
}
151150

152-
export function setupCache(nuxt: Nuxt) {
151+
export async function setupCache(nuxt: Nuxt) {
153152
// Add Server caching (Nitro)
153+
const driver = await resolvePath('./runtime/cache/driver')
154154
nuxt.options.nitro = defu(nuxt.options.nitro, {
155155
storage: {
156156
cache: {
157-
driver: 'cloudflare-kv-binding',
158-
binding: 'CACHE',
159-
base: 'cache'
157+
driver,
158+
binding: 'CACHE'
160159
}
161160
},
162161
devStorage: {
163162
cache: {
164-
driver: 'fs',
165-
base: join(nuxt.options.rootDir, '.data/cache')
163+
driver,
164+
binding: 'CACHE'
166165
}
167166
}
168167
})
@@ -343,7 +342,7 @@ export async function setupRemote(_nuxt: Nuxt, hub: HubConfig) {
343342
const availableStorages = Object.keys(remoteManifest?.storage || {}).filter(k => hub[k as keyof typeof hub] && remoteManifest?.storage[k])
344343
if (availableStorages.length > 0) {
345344
const storageDescriptions = availableStorages.map((storage) => {
346-
if (storage === 'vectorize' && hub.vectorize) {
345+
if (storage === 'vectorize' && Object.keys(hub.vectorize || {}).length) {
347346
const indexes = Object.keys(remoteManifest!.storage.vectorize!).join(', ')
348347
return `\`${storage} (${indexes})\``
349348
}

src/module.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default defineNuxtModule<ModuleOptions>({
107107
hub.analytics && setupAnalytics(nuxt)
108108
hub.blob && setupBlob(nuxt)
109109
hub.browser && await setupBrowser(nuxt)
110-
hub.cache && setupCache(nuxt)
110+
hub.cache && await setupCache(nuxt)
111111
hub.database && setupDatabase(nuxt)
112112
hub.kv && setupKV(nuxt)
113113
Object.keys(hub.vectorize!).length && setupVectorize(nuxt, hub)
@@ -143,18 +143,10 @@ export default defineNuxtModule<ModuleOptions>({
143143
if (hub.remote) {
144144
await setupRemote(nuxt, hub)
145145
vectorizeRemoteCheck(hub)
146-
return
147-
}
148-
149-
// Add node:stream to unenv external (only for Cloudflare Pages/Workers)
150-
if (!nuxt.options.nitro.unenv.external.includes('node:stream')) {
151-
nuxt.options.nitro.unenv.external.push('node:stream')
152146
}
153147

154-
// Folowing lines are only executed when remote storage is disabled
155-
156148
// Production mode without remote storage
157-
if (!nuxt.options.dev) {
149+
if (!hub.remote && !nuxt.options.dev) {
158150
// Make sure to fallback to cloudflare-pages preset
159151
let preset = nuxt.options.nitro.preset = nuxt.options.nitro.preset || 'cloudflare-pages'
160152
// Support also cloudflare_module
@@ -170,6 +162,11 @@ export default defineNuxtModule<ModuleOptions>({
170162
nuxt.options.nitro.commands.preview = 'npx nuxthub preview'
171163
nuxt.options.nitro.commands.deploy = 'npx nuxthub deploy'
172164

165+
// Add node:stream to unenv external (only for Cloudflare Pages/Workers)
166+
if (!nuxt.options.nitro.unenv.external.includes('node:stream')) {
167+
nuxt.options.nitro.unenv.external.push('node:stream')
168+
}
169+
173170
// Add the env middleware
174171
nuxt.options.nitro.handlers ||= []
175172
nuxt.options.nitro.handlers.unshift({
@@ -178,9 +175,11 @@ export default defineNuxtModule<ModuleOptions>({
178175
})
179176
}
180177

181-
// Local development without remote connection
178+
// Local development
182179
if (nuxt.options.dev) {
183-
log.info(`Using local storage from \`${hub.dir}\``)
180+
if (!hub.remote) {
181+
log.info(`Using local storage from \`${hub.dir}\``)
182+
}
184183

185184
// Create the hub.dir directory
186185
const hubDir = join(rootDir, hub.dir)
@@ -201,7 +200,7 @@ export default defineNuxtModule<ModuleOptions>({
201200
await writeFile(gitignorePath, `${gitignore ? gitignore + '\n' : gitignore}.data`, 'utf-8')
202201
}
203202

204-
const needWrangler = Boolean(hub.analytics || hub.blob || hub.database || hub.kv)
203+
const needWrangler = Boolean(hub.analytics || hub.blob || hub.database || hub.kv || hub.cache)
205204
// const needWrangler = Boolean(hub.analytics || hub.blob || hub.database || hub.kv || Object.keys(hub.bindings.hyperdrive).length > 0)
206205
if (needWrangler) {
207206
// Generate the wrangler.toml file

src/runtime/base/server/utils/hooks.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createHooks } from 'hookable'
2-
import { useRuntimeConfig } from '#imports'
32

43
export interface HubHooks {
54
'bindings:ready': () => void
@@ -28,8 +27,7 @@ export const hubHooks = createHooks<HubHooks>()
2827
* @see https://hub.nuxt.com/docs/recipes/hooks#onhubready
2928
*/
3029
export function onHubReady(cb: HubHooks['bindings:ready']) {
31-
const hub = useRuntimeConfig().hub
32-
if (import.meta.dev && !hub.remote) {
30+
if (import.meta.dev) {
3331
return hubHooks.hookOnce('bindings:ready', cb)
3432
}
3533
cb()

src/runtime/cache/driver.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks'
2+
import { defineDriver } from 'unstorage'
3+
import cloudflareKVBindingDriver from 'unstorage/drivers/cloudflare-kv-binding'
4+
import { getContext } from 'unctx'
5+
6+
export const nitroAsyncContext = getContext('nitro-app', {
7+
asyncContext: true,
8+
AsyncLocalStorage
9+
})
10+
11+
export default defineDriver((driverOpts) => {
12+
const driver = cloudflareKVBindingDriver(driverOpts)
13+
14+
return {
15+
name: 'nuxthub-cache',
16+
...driver,
17+
setItem(key, value, options) {
18+
const event = nitroAsyncContext.tryUse()?.event
19+
// TODO: remove this if once Nitro 2.10 is out with Nuxt version
20+
// As this does not support properly swr (as expiration should not be used)
21+
// Fallback to expires value ({"expires":1729118447040,...})
22+
if (!options.ttl && typeof value === 'string') {
23+
const expires = value.match(/^\{"expires":(\d+),/)?.[1]
24+
if (expires) {
25+
options.ttl = Math.round((Number(expires) - Date.now()) / 1000)
26+
}
27+
}
28+
if (options.ttl) {
29+
// Make sure to have a ttl of at least 60 seconds (Cloudflare KV limitation)
30+
options.ttl = Math.max(options.ttl, 60)
31+
}
32+
33+
options.metadata = {
34+
ttl: options.ttl,
35+
mtime: Date.now(),
36+
size: value.length,
37+
path: event?.path,
38+
...options.metadata
39+
}
40+
return driver.setItem(key, value, options)
41+
}
42+
}
43+
})

src/runtime/cache/plugins/cache.ts

Whitespace-only changes.

src/runtime/cache/server/api/_hub/cache/[...key].delete.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { eventHandler, getRouterParam, createError, sendNoContent } from 'h3'
22
import { requireNuxtHubAuthorization } from '../../../../../utils/auth'
33
import { requireNuxtHubFeature } from '../../../../../utils/features'
4-
// @ts-expect-error useStorage not yet typed
54
import { useStorage } from '#imports'
65

76
export default eventHandler(async (event) => {

src/runtime/cache/server/api/_hub/cache/[...key].get.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,69 @@ import ms from 'ms'
22
import { eventHandler, getRouterParam } from 'h3'
33
import { requireNuxtHubAuthorization } from '../../../../../utils/auth'
44
import { requireNuxtHubFeature } from '../../../../../utils/features'
5-
// @ts-expect-error useStorage not yet typed
5+
import { hubCacheBinding } from '../../../utils/cache'
66
import { useStorage } from '#imports'
77

88
export default eventHandler(async (event) => {
99
await requireNuxtHubAuthorization(event)
1010
requireNuxtHubFeature('cache')
1111

12-
const key = getRouterParam(event, 'key') || ''
12+
const keyOrPrefix = (getRouterParam(event, 'key') || '').replace(/\//g, ':')
1313
// If ends with an extension
14-
if (/\.[a-z0-9]{2,5}$/i.test(key)) {
15-
const item = await useStorage('cache').getItem(key)
14+
if (/\.[a-z0-9]{2,5}$/i.test(keyOrPrefix)) {
15+
const item = await useStorage('cache').getItem(keyOrPrefix)
1616
if (item) {
1717
return item
1818
}
1919
// Ignore if item is not found, treat the key as a prefix and look for children
2020
}
21-
const storage = useStorage(`cache:${key}`)
22-
const keys = await storage.getKeys()
21+
const prefix = `${keyOrPrefix}:`
22+
const binding = hubCacheBinding()
23+
const keys = []
24+
let cursor = undefined
25+
do {
26+
const res = await binding.list({ prefix, cursor })
27+
28+
keys.push(...res.keys)
29+
cursor = (res.list_complete ? undefined : res.cursor)
30+
} while (cursor)
2331

2432
const stats = {
2533
groups: {} as Record<string, number>,
2634
cache: [] as any[]
2735
}
2836

29-
await Promise.all(keys.map(async (key: string) => {
37+
await Promise.all(keys.map(async ({ name, metadata }) => {
38+
const key = name.slice(prefix.length)
3039
if (key.includes(':')) {
3140
const k = key.split(':')[0]
3241
stats.groups[k] = (stats.groups[k] || 0) + 1
3342
return
3443
}
35-
const item = await storage.getItem(key)
36-
if (!item) return
37-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
38-
const { value, ...meta } = item
3944

45+
// Fallback to read from storage if metadata is not available
46+
if (!metadata) {
47+
const item = await useStorage('cache').getItem(name)
48+
if (!item) return
49+
50+
metadata = {
51+
size: JSON.stringify(item).length,
52+
mtime: item.mtime,
53+
expires: item.expires
54+
}
55+
}
56+
57+
if (!metadata.expires && metadata.ttl) {
58+
metadata.expires = metadata.mtime + (metadata.ttl * 1000)
59+
}
4060
const entry = {
4161
key,
42-
...meta,
43-
size: JSON.stringify(item).length
62+
...metadata
4463
}
4564
try {
46-
entry.duration = ms(meta.expires - meta.mtime, { long: true })
65+
entry.duration = ms(metadata.expires - metadata.mtime, { long: true })
4766
} catch (err) {
48-
entry.duration = 'unknown'
67+
entry.duration = 'never'
4968
}
5069
stats.cache.push(entry)
5170
}))

0 commit comments

Comments
 (0)