diff --git a/.changeset/cache-consolidate-override.md b/.changeset/cache-consolidate-override.md new file mode 100644 index 00000000..00547bd6 --- /dev/null +++ b/.changeset/cache-consolidate-override.md @@ -0,0 +1,32 @@ +--- +"@opennextjs/core": major +"@opennextjs/cloudflare": minor +--- + +Route all caching through the `cache` override + +The incremental cache and the tag cache no longer run inside the server function. They run in the +cache handler function, which the server, the middleware and the composable cache reach through the +`cache` override. Tag revalidation - `hasBeenRevalidated`, `writeTags` and CDN invalidation - moves +with them, so `get`, `set` and `revalidateTags` now handle tags transparently. + +`incrementalCache` and `tagCache` are removed from `default.override` and from the middleware +override. Configurations that are not created by `defineCloudflareConfig` should move them to the +top level `cacheHandler` option and set `default.override.cache`: + +```diff + default: { + override: { +- incrementalCache: "s3", +- tagCache: "dynamodb", ++ cache: "local", + }, + }, ++ cacheHandler: { ++ incrementalCache: "s3", ++ tagCache: "dynamodb", ++ }, +``` + +`defineCloudflareConfig` is unchanged: it now wires the cache to the `OpenNextCache` entrypoint on +its own. diff --git a/examples/app-pages-router/open-next.config.ts b/examples/app-pages-router/open-next.config.ts index c1124cb4..65a9a7e9 100644 --- a/examples/app-pages-router/open-next.config.ts +++ b/examples/app-pages-router/open-next.config.ts @@ -3,9 +3,8 @@ import type { OpenNextConfig, OverrideOptions } from "@opennextjs/core/types/ope const devOverride = { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", + cache: "local", queue: "direct", - tagCache: "fs-dev-nextMode", } satisfies OverrideOptions; export default { @@ -26,6 +25,14 @@ export default { }, loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, // You can override the build command here so that you don't have to rebuild next every time you make a change // buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/app-router/open-next.config.ts b/examples/app-router/open-next.config.ts index b08f90fe..15554a52 100644 --- a/examples/app-router/open-next.config.ts +++ b/examples/app-router/open-next.config.ts @@ -5,9 +5,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", + cache: "local", queue: "direct", - tagCache: "fs-dev-nextMode", }, }, @@ -19,6 +18,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/experimental/open-next.config.ts b/examples/experimental/open-next.config.ts index b08f90fe..b3444c42 100644 --- a/examples/experimental/open-next.config.ts +++ b/examples/experimental/open-next.config.ts @@ -5,9 +5,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", queue: "direct", - tagCache: "fs-dev-nextMode", + cache: "local", }, }, @@ -19,6 +18,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/pages-router/open-next.config.ts b/examples/pages-router/open-next.config.ts index e3fd064f..67cc2788 100644 --- a/examples/pages-router/open-next.config.ts +++ b/examples/pages-router/open-next.config.ts @@ -3,9 +3,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", queue: "direct", - tagCache: "dummy", + cache: "local", }, }, @@ -17,6 +16,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "dummy", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", }; diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index 7eab334c..9e8a2528 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -10,9 +10,14 @@ export default buildAdapter((_config, buildOpts: BuildOptions) => ({ server: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.js", converter: "@opennextjs/aws/overrides/converters/aws-streaming.js", + cache: "@opennextjs/core/overrides/cache/local.js", + queue: "@opennextjs/aws/overrides/queue/sqs.js", + }, + cache: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", - queue: "@opennextjs/aws/overrides/queue/sqs.js", }, revalidation: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", @@ -31,8 +36,7 @@ export default buildAdapter((_config, buildOpts: BuildOptions) => ({ middleware: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", converter: "@opennextjs/aws/overrides/converters/aws-cloudfront.js", - incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3-lite.js", - tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb-lite.js", + cache: "@opennextjs/core/overrides/cache/local.js", queue: "@opennextjs/aws/overrides/queue/sqs-lite.js", }, }, diff --git a/packages/cloudflare/src/api/config.spec.ts b/packages/cloudflare/src/api/config.spec.ts index a571874c..674777f0 100644 --- a/packages/cloudflare/src/api/config.spec.ts +++ b/packages/cloudflare/src/api/config.spec.ts @@ -22,8 +22,7 @@ describe("defineCloudflareConfig", () => { wrapper: "node", converter: "node", generateDockerfile: true, - incrementalCache: "dummy", - tagCache: "dummy", + cache: "dummy", queue: "dummy", }); expect(config.middleware).toMatchObject({ diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 9fe26773..cd1f82e7 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -13,6 +13,7 @@ import type { } from "@opennextjs/core/types/overrides.js"; import assetResolver from "./overrides/asset-resolver/index.js"; +import serviceCache from "./overrides/cache/service-cache.js"; export type Override = "dummy" | T | LazyLoadedOverride; @@ -89,16 +90,14 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe wrapper: "node" as const, converter: "node" as const, generateDockerfile: true, - incrementalCache: "dummy" as const, - tagCache: "dummy" as const, + cache: "dummy" as const, queue: "dummy" as const, } : { wrapper: "cloudflare-node" as const, converter: "edge" as const, proxyExternalRequest: "fetch" as const, - incrementalCache: resolveIncrementalCache(incrementalCache), - tagCache: resolveTagCache(tagCache), + cache: () => serviceCache, queue: resolveQueue(queue), cdnInvalidation: resolveCdnInvalidation(cachePurge), }; @@ -108,6 +107,12 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe override: defaultOverride, routePreloadingBehavior, }, + // The cache runs in the same worker, behind the `OpenNextCache` named entrypoint. + cacheHandler: { + incrementalCache: resolveIncrementalCache(incrementalCache), + tagCache: resolveTagCache(tagCache), + cdnInvalidation: resolveCdnInvalidation(cachePurge), + }, // node:crypto is used to compute cache keys edgeExternals: ["node:crypto"], cloudflare: { @@ -120,8 +125,7 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe wrapper: "cloudflare-edge", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: resolveIncrementalCache(incrementalCache), - tagCache: resolveTagCache(tagCache), + cache: () => serviceCache, queue: resolveQueue(queue), }, assetResolver: () => assetResolver, diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 3854dcb6..0964e2f8 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -28,13 +28,20 @@ describe("serviceCache", () => { }); describe("get", () => { - it("requests the key and the cache type", async () => { - await serviceCache.get("key/with/slashes", "fetch"); + it("requests the key, the cache type and the additional tags", async () => { + await serviceCache.get("key/with/slashes", "fetch", ["tag1", "tag2"]); const { url, method } = lastRequest(); expect(method).toBe("GET"); expect(url.pathname).toBe(`/cache/${encodeURIComponent("key/with/slashes")}`); expect(url.searchParams.get("type")).toBe("fetch"); + expect(url.searchParams.get("tags")).toBe("tag1,tag2"); + }); + + it("omits the tags when there is none", async () => { + await serviceCache.get("key", "cache", []); + + expect(lastRequest().url.searchParams.has("tags")).toBe(false); }); it("returns null on a cache miss", async () => { @@ -63,13 +70,17 @@ describe("serviceCache", () => { describe("set", () => { // The cache type is part of the key for the incremental caches, it has to be forwarded // or entries would be written where they are not read from. - it("sends the value and the cache type", async () => { - await serviceCache.set("key", { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, "fetch"); + it("sends the value, cache type, and additional tags", async () => { + await serviceCache.set("key", { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, "fetch", [ + "tag1", + "tag2", + ]); const { url, method, body } = lastRequest(); expect(method).toBe("PUT"); expect(url.pathname).toBe("/cache/key"); expect(url.searchParams.get("type")).toBe("fetch"); + expect(url.searchParams.get("tags")).toBe("tag1,tag2"); expect(JSON.parse(body as string)).toEqual({ value: { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, }); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index fd18f0e2..8b1a840d 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -35,12 +35,16 @@ function getCacheService(): Service { return service; } -function getCacheUrl(key: string, cacheType?: CacheEntryType) { +function getCacheUrl(key: string, cacheType?: CacheEntryType, additionalTags?: string[]) { const url = new URL(`/cache/${encodeURIComponent(key)}`, CACHE_ORIGIN); if (cacheType) { url.searchParams.set("type", cacheType); } + if (additionalTags && additionalTags.length > 0) { + url.searchParams.set("tags", additionalTags.join(",")); + } + return url.href; } @@ -67,8 +71,8 @@ function ensureResponseOk(response: Pick, operation: const serviceCache = { name: NAME, - get: async (key, cacheType) => { - const response = await getCacheService().fetch(getCacheUrl(key, cacheType)); + get: async (key, cacheType, additionalTags) => { + const response = await getCacheService().fetch(getCacheUrl(key, cacheType, additionalTags)); const body = await response.text(); const headers: Record = {}; @@ -80,8 +84,8 @@ const serviceCache = { return parseCacheGetResponse(headers, body) as any; }, - set: async (key, value, cacheType) => { - const response = await getCacheService().fetch(getCacheUrl(key, cacheType), { + set: async (key, value, cacheType, additionalTags) => { + const response = await getCacheService().fetch(getCacheUrl(key, cacheType, additionalTags), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }), diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index a4f78b12..0dfd492d 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -34,7 +34,10 @@ export function computeCacheKey(key: string, options: KeyOptions) { export function isPurgeCacheEnabled(): boolean { // The `?` is required at `openNextConfig?` or the Open Next build fails because of a type error - const cdnInvalidation = globalThis.openNextConfig?.default?.override?.cdnInvalidation; + // The cache handler function only has `cacheHandler` populated, the other functions only have `default`. + const cdnInvalidation = + globalThis.openNextConfig?.cacheHandler?.cdnInvalidation ?? + globalThis.openNextConfig?.default?.override?.cdnInvalidation; return cdnInvalidation !== undefined && cdnInvalidation !== "dummy"; } diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts index 464d6b48..f75f853c 100644 --- a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts @@ -11,7 +11,7 @@ describe("ensureCloudflareConfig", () => { test("rejects binding-backed caches in container mode", () => { const config = defineCloudflareConfig({ container: true }); - config.default.override!.incrementalCache = () => ({ name: "unsupported" }) as never; + config.default.override!.cache = () => ({ name: "unsupported" }) as never; expect(() => ensureCloudflareConfig(config)).toThrow("Cloudflare Containers"); }); diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts index 7740469a..02d3e074 100644 --- a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts @@ -23,12 +23,12 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { dftUseCloudflareWrapper: config.default?.override?.wrapper === "cloudflare-node", dftUseEdgeConverter: config.default?.override?.converter === "edge", dftUseFetchProxy: config.default?.override?.proxyExternalRequest === "fetch", - dftMaybeUseCache: - config.default?.override?.incrementalCache === "dummy" || - typeof config.default?.override?.incrementalCache === "function", - dftMaybeUseTagCache: - config.default?.override?.tagCache === "dummy" || - typeof config.default?.override?.incrementalCache === "function", + dftUseCacheClient: typeof config.default?.override?.cache === "function", + chMaybeUseIncrementalCache: + config.cacheHandler?.incrementalCache === "dummy" || + typeof config.cacheHandler?.incrementalCache === "function", + chMaybeUseTagCache: + config.cacheHandler?.tagCache === "dummy" || typeof config.cacheHandler?.tagCache === "function", dftMaybeUseQueue: config.default?.override?.queue === "dummy" || config.default?.override?.queue === "direct" || @@ -36,14 +36,14 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { dftUseNodeWrapper: config.default?.override?.wrapper === "node", dftUseNodeConverter: config.default?.override?.converter === "node", dftGenerateDockerfile: config.default?.override?.generateDockerfile === true, - dftUseDummyCache: config.default?.override?.incrementalCache === "dummy", - dftUseDummyTagCache: config.default?.override?.tagCache === "dummy", + dftUseDummyCache: config.default?.override?.cache === "dummy", dftUseDummyQueue: config.default?.override?.queue === "dummy", // Check for the middleware function mwIsMiddlewareExternal, mwUseCloudflareWrapper: mwConfig?.override?.wrapper === "cloudflare-edge", mwUseEdgeConverter: mwConfig?.override?.converter === "edge", mwUseFetchProxy: mwConfig?.override?.proxyExternalRequest === "fetch", + mwUseCacheClient: typeof mwConfig?.override?.cache === "function", hasCryptoExternal: config.edgeExternals?.includes("node:crypto"), }; @@ -55,8 +55,9 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { requirements.dftUseCloudflareWrapper, requirements.dftUseEdgeConverter, requirements.dftUseFetchProxy, - requirements.dftMaybeUseCache, - requirements.dftMaybeUseTagCache, + requirements.dftUseCacheClient, + requirements.chMaybeUseIncrementalCache, + requirements.chMaybeUseTagCache, requirements.dftMaybeUseQueue, ]; const containerRequirements = [ @@ -64,7 +65,6 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { requirements.dftUseNodeConverter, requirements.dftGenerateDockerfile, requirements.dftUseDummyCache, - requirements.dftUseDummyTagCache, requirements.dftUseDummyQueue, ]; const commonRequirements = [ @@ -72,6 +72,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { requirements.mwUseCloudflareWrapper, requirements.mwUseEdgeConverter, requirements.mwUseFetchProxy, + requirements.mwUseCacheClient, requirements.hasCryptoExternal, ]; @@ -86,11 +87,14 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { wrapper: "cloudflare-node", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: "dummy" | function, - tagCache: "dummy" | function, + cache: function, queue: "dummy" | "direct" | function, }, }, + cacheHandler: { + incrementalCache: "dummy" | function, + tagCache: "dummy" | function, + }, edgeExternals: ["node:crypto"], middleware: { external: true, @@ -98,8 +102,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { wrapper: "cloudflare-edge", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: "dummy" | function, - tagCache: "dummy" | function, + cache: function, queue: "dummy" | "direct" | function, }, }, diff --git a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts index 9127dd1b..abf4e0c6 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts @@ -113,10 +113,8 @@ describe("populateCache", () => { outputDir: "/test/output", } as BuildOptions, { - default: { - override: { - incrementalCache: "cf-r2-incremental-cache", - }, + cacheHandler: { + incrementalCache: "cf-r2-incremental-cache", }, } as any, // oxlint-disable-line @typescript-eslint/no-explicit-any { @@ -149,10 +147,8 @@ describe("populateCache", () => { outputDir: "/test/output", } as BuildOptions, { - default: { - override: { - incrementalCache: "cf-r2-incremental-cache", - }, + cacheHandler: { + incrementalCache: "cf-r2-incremental-cache", }, } as any, // oxlint-disable-line @typescript-eslint/no-explicit-any { diff --git a/packages/cloudflare/src/cli/commands/populate-cache.ts b/packages/cloudflare/src/cli/commands/populate-cache.ts index c60aa20c..fbe6692a 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.ts @@ -90,7 +90,7 @@ export async function populateCache( populateCacheOptions: PopulateCacheOptions, envVars: WorkerEnvVar ) { - const { incrementalCache, tagCache } = config.default.override ?? {}; + const { incrementalCache, tagCache } = config.cacheHandler ?? {}; if (!fs.existsSync(buildOpts.outputDir)) { logger.error("Unable to populate cache: Open Next build not found"); diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 79b2013c..19b0426d 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -13,7 +13,7 @@ import type { } from "@/types/overrides"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { writeTags } from "../utils/cache.js"; +import { getTagsFromValue, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { fromReadableStream, toReadableStream } from "../utils/stream.js"; @@ -30,13 +30,9 @@ async function initializeCaches() { if (initialized) return; const config = globalThis.openNextConfig; - globalThis.incrementalCache = await resolveIncrementalCache( - config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache - ); + globalThis.incrementalCache = await resolveIncrementalCache(config.cacheHandler?.incrementalCache); - globalThis.tagCache = await resolveTagCache( - config.cacheHandler?.tagCache ?? config.default?.override?.tagCache - ); + globalThis.tagCache = await resolveTagCache(config.cacheHandler?.tagCache); globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation @@ -90,12 +86,13 @@ export async function handler( const rawType = typeof query?.type === "string" ? query.type : undefined; const cacheType: CacheEntryType = rawType === "fetch" || rawType === "composable" ? rawType : "cache"; + const additionalTags = query?.tags ? (query.tags as string).split(",") : []; switch (method) { case "GET": - return await handleGet(key, cacheType); + return await handleGet(key, cacheType, additionalTags); case "PUT": - return await handleSet(key, cacheType, body); + return await handleSet(key, cacheType, additionalTags, body); case "DELETE": return await handleDelete(key); default: @@ -111,8 +108,12 @@ export async function handler( // Route handlers // ////////////////////// -async function handleGet(key: string, cacheType: CacheEntryType): Promise { - debug("get", { key, cacheType }); +async function handleGet( + key: string, + cacheType: CacheEntryType, + additionalTags: string[] +): Promise { + debug("get", { key, cacheType, additionalTags }); try { const result = await globalThis.incrementalCache.get(key, cacheType); @@ -130,6 +131,50 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise)]; + } else if (cacheType === "fetch") { + const fetchValue = result.value as CachedFetchValue; + tags = [...tags, ...(fetchValue.tags ?? []), ...(fetchValue.data?.tags ?? [])]; + } else if (cacheType === "composable") { + const composableValue = result.value as StoredComposableCacheEntry; + tags = [...tags, ...(composableValue.tags ?? [])]; + } + + if (!result.shouldBypassTagCache) { + let revalidated = await checkTagRevalidation(key, tags, result); + + if (cacheType === "fetch" && globalThis.tagCache.mode === "original") { + const hasHardTags = additionalTags.some((tag) => !tag.startsWith(SOFT_TAG_PREFIX)); + const path = additionalTags.find( + (tag) => tag.startsWith(SOFT_TAG_PREFIX) && !tag.endsWith("layout") && !tag.endsWith("page") + ); + + if (!revalidated && !hasHardTags && path) { + revalidated = await checkTagRevalidation(path.slice(SOFT_TAG_PREFIX.length), [], result); + } + } + + if (revalidated) { + return { + type: "core", + statusCode: 404, + body: toReadableStream(""), + isBase64Encoded: false, + headers: { + "x-opennext-cache-found": "false", + "x-opennext-cache-tag-status": "revalidated", + "Cache-Control": "no-store", + }, + }; + } + } + return buildCacheGetResponse(result); } catch (e) { error("Failed to get cache entry", e); @@ -137,11 +182,31 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise> +): Promise { + if ( + globalThis.openNextConfig?.dangerous?.disableTagCache || + (globalThis.tagCache.mode === "nextMode" && tags.length === 0) + ) { + return false; + } + const lastModified = cacheEntry.lastModified ?? Date.now(); + if (globalThis.tagCache.mode === "nextMode") { + return globalThis.tagCache.hasBeenRevalidated(tags, lastModified); + } + const _lastModified = await globalThis.tagCache.getLastModified(key, lastModified); + return _lastModified === -1; +} + /** * Stores a cache entry from a streamed JSON request body. * * @param key Cache key to update. * @param cacheType Type of cache entry being stored. + * @param additionalTags Tags supplied separately from the cache value. * @param body Stream containing the serialized cache value. * @return The cache operation response. * @throws When reading the request stream fails. @@ -149,6 +214,7 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise ): Promise { debug("set", { key, cacheType }); @@ -172,6 +238,45 @@ async function handleSet( try { await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); + + // `writeTags` deduplicates through the OpenNext request context and gives up when there is + // none. The cache handler runs as its own function, so nothing established a context for us. + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + // Write tags for non-composable and non-nextMode tag caches + const tagCache = globalThis.tagCache; + // TODO: fix this horrible typing + if (tagCache.mode !== "nextMode" && !globalThis.openNextConfig?.dangerous?.disableTagCache) { + let derivedTags: string[] = [...additionalTags]; + + if (cacheType === "cache") { + const tags = getTagsFromValue(payload.value as CacheValue<"cache">); + derivedTags.push(...tags); + } else if (cacheType === "fetch") { + const fetchValue = payload.value as CacheValue<"fetch">; + const data = fetchValue.data as Record | undefined; + derivedTags.push(...((fetchValue.tags as string[]) ?? (data?.tags as string[]) ?? [])); + } else if (cacheType === "composable") { + const composableValue = payload.value as CacheValue<"composable">; + derivedTags.push(...(composableValue.tags ?? [])); + } + + if (derivedTags.length > 0) { + const storedTags = await tagCache.getByPath(key); + const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); + if (tagsToWrite.length > 0) { + await writeTags( + tagsToWrite.map((tag) => ({ + path: key, + tag, + revalidatedAt: 1, + })), + tagCache + ); + } + } + } + }); + return buildJsonResponse({ ok: true }, 200); } catch (e) { error("Failed to set cache entry", e); diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index cb8a65f6..716a3cda 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -1,21 +1,9 @@ import type { CacheHandlerValue, IncrementalCacheContext, IncrementalCacheValue } from "@/types/cache"; -import { getTagsFromValue, hasBeenRevalidated, writeTags } from "@/utils/cache"; import { isBinaryContentType } from "../utils/binary"; import { debug, error, warn } from "./logger"; -export const SOFT_TAG_PREFIX = "_N_T_/"; - -/** - * Selects the dedicated cache transport when one is configured. - * - * @return The effective cache storage implementation. - */ -function getCacheStorage() { - return globalThis.cache ?? globalThis.incrementalCache; -} - function isFetchCache(options?: { kindHint?: "app" | "pages" | "fetch"; kind?: "FETCH" }): boolean { if (typeof options === "object") { return options.kindHint === "fetch" || options.kind === "FETCH"; @@ -38,47 +26,21 @@ export default class Cache { return null; } - const softTags = typeof options === "object" ? options.softTags : []; - const tags = typeof options === "object" ? options.tags : []; - return isFetchCache(options) ? this.getFetchCache(key, softTags, tags) : this.getIncrementalCache(key); + return isFetchCache(options) + ? this.getFetchCache(key, [...(options?.tags ?? []), ...(options?.softTags ?? [])]) + : this.getIncrementalCache(key); } - async getFetchCache(key: string, softTags?: string[], tags?: string[]) { - debug("get fetch cache", { key, softTags, tags }); + async getFetchCache(key: string, additionalTags: string[] = []): Promise { + debug("get fetch cache", { key }); try { - const cachedEntry = await getCacheStorage().get(key, "fetch"); - - if (cachedEntry?.value === undefined) return null; - - const _tags = [...(tags ?? []), ...(softTags ?? [])]; - const _lastModified = cachedEntry.lastModified ?? Date.now(); - const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated<"fetch">(key, _tags, cachedEntry); - - if (_hasBeenRevalidated) return null; + const result = await globalThis.cache.get(key, "fetch", additionalTags); - // For cases where we don't have tags, we need to ensure that the soft tags are not being revalidated - // We only need to check for the path as it should already contain all the tags - if ((tags ?? []).length === 0) { - // Then we need to find the path for the given key - const path = softTags?.find( - (tag) => tag.startsWith(SOFT_TAG_PREFIX) && !tag.endsWith("layout") && !tag.endsWith("page") - ); - if (path) { - const hasPathBeenUpdated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated<"fetch">(path.replace(SOFT_TAG_PREFIX, ""), [], cachedEntry); - if (hasPathBeenUpdated) { - // In case the path has been revalidated, we don't want to use the fetch cache - return null; - } - } - } + if (!result?.value) return null; return { - lastModified: _lastModified, - value: cachedEntry.value, + lastModified: result.lastModified ?? Date.now(), + value: result.value, } as CacheHandlerValue; } catch (e) { // We can usually ignore errors here as they are usually due to cache not being found @@ -89,7 +51,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await getCacheStorage().get(key, "cache"); + const cachedEntry = await globalThis.cache.get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -98,12 +60,7 @@ export default class Cache { const cacheData = cachedEntry.value; const meta = cacheData.meta; - const tags = getTagsFromValue(cacheData); const _lastModified = cachedEntry.lastModified ?? Date.now(); - const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated(key, tags, cachedEntry); - if (_hasBeenRevalidated) return null; const store = globalThis.__openNextAls.getStore(); if (store) { @@ -183,14 +140,14 @@ export default class Cache { const detachedPromise = globalThis.__openNextAls.getStore()?.pendingPromiseRunner.withResolvers(); try { if (data === null || data === undefined) { - await getCacheStorage().delete(key); + await globalThis.cache.delete(key); } else { const revalidate = this.extractRevalidateForSet(ctx); switch (data.kind) { case "ROUTE": case "APP_ROUTE": { const { body, status, headers } = data; - await getCacheStorage().set( + await globalThis.cache.set( key, { type: "route", @@ -210,7 +167,7 @@ export default class Cache { const { html, pageData, status, headers } = data; const isAppPath = typeof pageData === "string"; if (isAppPath) { - await getCacheStorage().set( + await globalThis.cache.set( key, { type: "app", @@ -225,7 +182,7 @@ export default class Cache { "cache" ); } else { - await getCacheStorage().set( + await globalThis.cache.set( key, { type: "page", @@ -233,7 +190,8 @@ export default class Cache { json: pageData, revalidate, }, - "cache" + "cache", + headers?.["x-next-cache-tags"]?.split(",") ); } break; @@ -246,7 +204,7 @@ export default class Cache { segmentToWrite[segmentPath] = segmentContent.toString("utf8"); } } - await getCacheStorage().set( + await globalThis.cache.set( key, { type: "app", @@ -265,10 +223,15 @@ export default class Cache { break; } case "FETCH": - await getCacheStorage().set(key, data, "fetch"); + await globalThis.cache.set( + key, + data, + "fetch", + typeof ctx === "object" && "tags" in ctx ? ctx.tags : undefined + ); break; case "REDIRECT": - await getCacheStorage().set( + await globalThis.cache.set( key, { type: "redirect", @@ -284,7 +247,6 @@ export default class Cache { } } - await this.updateTagsOnSet(key, data, ctx); debug("Finished setting cache"); } catch (e) { error("Failed to set cache", e); @@ -305,140 +267,12 @@ export default class Cache { } try { - if (globalThis.cache) { - await globalThis.cache.revalidateTags(_tags); - return; - } - - if (globalThis.tagCache.mode === "nextMode") { - const paths = (await globalThis.tagCache.getPathsByTags?.(_tags)) ?? []; - - await writeTags(_tags); - if (paths.length > 0) { - // TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it - // It also means that we'll need to provide the tags used in every request to the wrapper or converter. - await globalThis.cdnInvalidationHandler.invalidatePaths( - paths.map((path) => ({ - initialPath: path, - rawPath: path, - resolvedRoutes: [ - { - route: path, - // TODO: ideally here we should check if it's an app router page or route - type: "app", - isFallback: false, - }, - ], - })) - ); - } - return; - } - - for (const tag of _tags) { - debug("revalidateTag", tag); - // Find all keys with the given tag - const paths = await globalThis.tagCache.getByTag(tag); - debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); - - // If the tag is a soft tag, we should also revalidate the hard tags - if (tag.startsWith(SOFT_TAG_PREFIX)) { - for (const path of paths) { - // We need to find all hard tags for a given path - const _tags = await globalThis.tagCache.getByPath(path); - const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); - // For every hard tag, we need to find all paths and revalidate them - for (const hardTag of hardTags) { - const _paths = await globalThis.tagCache.getByTag(hardTag); - debug({ hardTag, _paths }); - toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) - ); - } - } - } - - // Update all keys with the given tag with revalidatedAt set to now - await writeTags(toInsert); - - // We can now invalidate all paths in the CDN - // This only applies to `revalidateTag`, not to `res.revalidate()` - const uniquePaths = Array.from( - new Set( - toInsert - // We need to filter fetch cache key as they are not in the CDN - .filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX)) - .map((t) => `/${t.path}`) - ) - ); - if (uniquePaths.length > 0) { - await globalThis.cdnInvalidationHandler.invalidatePaths( - uniquePaths.map((path) => ({ - initialPath: path, - rawPath: path, - resolvedRoutes: [ - { - route: path, - // TODO: ideally here we should check if it's an app router page or route - type: "app", - isFallback: false, - }, - ], - })) - ); - } - } + await globalThis.cache.revalidateTags(_tags); } catch (e) { error("Failed to revalidate tag", e); } } - // TODO: We should delete/update tags in this method - // This will require an update to the tag cache interface - private async updateTagsOnSet(key: string, data?: IncrementalCacheValue, ctx?: IncrementalCacheContext) { - if ( - globalThis.openNextConfig.dangerous?.disableTagCache || - globalThis.tagCache.mode === "nextMode" || - // Here it means it's a delete - !data - ) { - return; - } - // Write derivedTags to the tag cache - // If we use an in house version of getDerivedTags in build we should use it here instead of next's one - const derivedTags: string[] = - data?.kind === "FETCH" - ? //@ts-expect-error - On older versions of next, ctx was a number, but for these cases we use data?.data?.tags - (ctx?.tags ?? data?.data?.tags ?? []) // before version 14 next.js used data?.data?.tags so we keep it for backward compatibility - : data?.kind === "PAGE" - ? (data.headers?.["x-next-cache-tags"]?.split(",") ?? []) - : []; - debug("derivedTags", derivedTags); - - // Get all tags stored in dynamodb for the given key - // If any of the derived tags are not stored in dynamodb for the given key, write them - const storedTags = await globalThis.tagCache.getByPath(key); - const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); - if (tagsToWrite.length > 0) { - await writeTags( - tagsToWrite.map((tag) => ({ - path: key, - tag: tag, - // In case the tags are not there we just need to create them - // but we don't want them to return from `getLastModified` as they are not stale - revalidatedAt: 1, - })) - ); - } - } - private extractRevalidateForSet(ctx?: IncrementalCacheContext): number | false | undefined { if (ctx === undefined) { return undefined; diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index 40a81f21..820fbd41 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -1,21 +1,11 @@ import type { ComposableCacheEntry, ComposableCacheHandler } from "@/types/cache"; import type { CacheValue } from "@/types/overrides"; -import { writeTags } from "@/utils/cache"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; import { debug } from "./logger"; const pendingWritePromiseMap = new Map>>(); -/** - * Selects the dedicated cache transport when one is configured. - * - * @return The effective cache storage implementation. - */ -function getCacheStorage() { - return globalThis.cache ?? globalThis.incrementalCache; -} - export default { async get(cacheKey: string) { try { @@ -30,26 +20,13 @@ export default { })); } } - const result = await getCacheStorage().get(cacheKey, "composable"); + const result = await globalThis.cache.get(cacheKey, "composable"); if (!result?.value?.value) { return undefined; } debug("composable cache result", result); - // We need to check if the tags associated with this entry has been revalidated - if (globalThis.tagCache.mode === "nextMode" && result.value.tags.length > 0) { - const hasBeenRevalidated = result.shouldBypassTagCache - ? false - : await globalThis.tagCache.hasBeenRevalidated(result.value.tags, result.lastModified); - if (hasBeenRevalidated) return undefined; - } else if (globalThis.tagCache.mode === "original" || globalThis.tagCache.mode === undefined) { - const hasBeenRevalidated = result.shouldBypassTagCache - ? false - : (await globalThis.tagCache.getLastModified(cacheKey, result.lastModified)) === -1; - if (hasBeenRevalidated) return undefined; - } - return { ...result.value, value: toReadableStream(result.value.value), @@ -70,7 +47,7 @@ export default { const entry = await promiseEntry.finally(() => { pendingWritePromiseMap.delete(cacheKey); }); - await getCacheStorage().set( + await globalThis.cache.set( cacheKey, { ...entry, @@ -78,13 +55,6 @@ export default { }, "composable" ); - if (globalThis.tagCache.mode === "original") { - const storedTags = await globalThis.tagCache.getByPath(cacheKey); - const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag)); - if (tagsToWrite.length > 0) { - await writeTags(tagsToWrite.map((tag) => ({ tag, path: cacheKey }))); - } - } }, async refreshTags() { @@ -98,12 +68,8 @@ export default { * - From Next.js 16, the method takes `tags: string[]` */ async getExpiration(...tags: string[] | string[][]) { - if (globalThis.tagCache.mode === "nextMode") { - // Use `.flat()` to accommodate both signatures - return globalThis.tagCache.getLastRevalidated(tags.flat()); - } - // We always return 0 here, original tag cache are handled directly in the get part - // TODO: We need to test this more, i'm not entirely sure that this is working as expected + // Tag revalidation is handled transparently in the cache layer's get(), + // so we always return 0 here to let get() determine freshness. return 0; }, @@ -111,32 +77,10 @@ export default { * This method is only used before Next.js 16 */ async expireTags(...tags: string[]) { - if (globalThis.cache) { - return globalThis.cache.revalidateTags(tags); - } - if (globalThis.tagCache.mode === "nextMode") { - return writeTags(tags); - } - const tagCache = globalThis.tagCache; - const revalidatedAt = Date.now(); - // For the original mode, we have more work to do here. - // We need to find all paths linked to to these tags - const pathsToUpdate = await Promise.all( - tags.map(async (tag) => { - const paths = await tagCache.getByTag(tag); - return paths.map((path) => ({ - path, - tag, - revalidatedAt, - })); - }) - ); - // We need to deduplicate paths, we use a set for that - const setToWrite = new Set<{ path: string; tag: string }>(); - for (const entry of pathsToUpdate.flat()) { - setToWrite.add(entry); + const flatTags = tags.flat(); + if (flatTags.length > 0) { + await globalThis.cache.revalidateTags(flatTags); } - await writeTags(Array.from(setToWrite)); }, // This one is necessary for older versions of next diff --git a/packages/core/src/adapters/middleware.ts b/packages/core/src/adapters/middleware.ts index 43038f8d..340e4aab 100644 --- a/packages/core/src/adapters/middleware.ts +++ b/packages/core/src/adapters/middleware.ts @@ -12,11 +12,9 @@ import { createGenericHandler } from "../core/createGenericHandler"; import { resolveAssetResolver, resolveCache, - resolveIncrementalCache, resolveOriginResolver, resolveProxyRequest, resolveQueue, - resolveTagCache, } from "../core/resolve"; import { constructNextUrl } from "../core/routing/util"; import routingHandler, { @@ -41,12 +39,8 @@ const defaultHandler = async ( const assetResolver = await resolveAssetResolver(middlewareConfig?.assetResolver); - globalThis.tagCache = await resolveTagCache(middlewareConfig?.override?.tagCache); - globalThis.queue = await resolveQueue(middlewareConfig?.override?.queue); - globalThis.incrementalCache = await resolveIncrementalCache(middlewareConfig?.override?.incrementalCache); - globalThis.cache = await resolveCache(middlewareConfig?.override?.cache); const requestId = Math.random().toString(36); diff --git a/packages/core/src/build/createCacheBundle.spec.ts b/packages/core/src/build/createCacheBundle.spec.ts index 8b9d07d4..28b41976 100644 --- a/packages/core/src/build/createCacheBundle.spec.ts +++ b/packages/core/src/build/createCacheBundle.spec.ts @@ -51,14 +51,12 @@ describe("createCacheBundle", () => { ); }); - test("falls back to legacy default function cache providers", async () => { + test("uses adapter cache provider defaults and the default function CDN fallback", async () => { const options = { buildDir: "/app/.open-next/.build", config: { default: { override: { - incrementalCache: "legacy-incremental", - tagCache: "legacy-tags", cdnInvalidation: "legacy-cdn", }, }, @@ -67,27 +65,30 @@ describe("createCacheBundle", () => { outputDir: "/app/.open-next", }; - await createCacheBundle(options as never); + await createCacheBundle(options as never, { + incrementalCache: "adapter-incremental", + tagCache: "adapter-tags", + }); expect(openNextResolvePlugin).toHaveBeenCalledWith( expect.objectContaining({ overrides: expect.objectContaining({ - incrementalCache: "legacy-incremental", - tagCache: "legacy-tags", cdnInvalidation: "legacy-cdn", }), + defaultOverrides: expect.objectContaining({ + incrementalCache: "adapter-incremental", + tagCache: "adapter-tags", + }), }) ); }); - test("prefers cache handler providers over legacy defaults", async () => { + test("prefers cache handler providers over adapter defaults", async () => { const options = { buildDir: "/app/.open-next/.build", config: { default: { override: { - incrementalCache: "legacy-incremental", - tagCache: "legacy-tags", cdnInvalidation: "legacy-cdn", }, }, @@ -101,7 +102,10 @@ describe("createCacheBundle", () => { outputDir: "/app/.open-next", }; - await createCacheBundle(options as never); + await createCacheBundle(options as never, { + incrementalCache: "adapter-incremental", + tagCache: "adapter-tags", + }); expect(openNextResolvePlugin).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/core/src/build/createCacheBundle.ts b/packages/core/src/build/createCacheBundle.ts index 375cc4b7..49f08908 100644 --- a/packages/core/src/build/createCacheBundle.ts +++ b/packages/core/src/build/createCacheBundle.ts @@ -45,9 +45,8 @@ export async function createCacheBundle( overrides: { converter: config.cacheHandler?.override?.converter, wrapper: config.cacheHandler?.override?.wrapper, - incrementalCache: - config.cacheHandler?.incrementalCache ?? config.default.override?.incrementalCache, - tagCache: config.cacheHandler?.tagCache ?? config.default.override?.tagCache, + incrementalCache: config.cacheHandler?.incrementalCache, + tagCache: config.cacheHandler?.tagCache, cdnInvalidation: config.cacheHandler?.cdnInvalidation ?? config.default.override?.cdnInvalidation, }, defaultOverrides: { diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index 2e090ccf..cbeef42f 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -77,8 +77,6 @@ export async function buildEdgeBundle({ overrides: { wrapper: override("wrapper"), converter: override("converter"), - tagCache: override("tagCache"), - incrementalCache: override("incrementalCache"), cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), @@ -87,9 +85,6 @@ export async function buildEdgeBundle({ defaultOverrides: { wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/dummy.js", converter: defaultOverrides?.converter ?? defaultConverter, - tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", - incrementalCache: - defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", cache: defaultOverrides?.cache ?? "@opennextjs/core/overrides/cache/dummy.js", queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", originResolver: diff --git a/packages/core/src/build/generateOutput.spec.ts b/packages/core/src/build/generateOutput.spec.ts index d5314121..f9e57763 100644 --- a/packages/core/src/build/generateOutput.spec.ts +++ b/packages/core/src/build/generateOutput.spec.ts @@ -84,6 +84,38 @@ describe("buildOpenNextOutput", () => { converter: "aws-streaming", }); }); + + test("reports cache providers on the cache function", async () => { + const opts = createMockBuildOpts(); + (opts.config as OpenNextConfig).cacheHandler = { + incrementalCache: "dummy", + tagCache: "dummy", + }; + + const output = await buildOpenNextOutput(opts); + + expect(output.origins.default).not.toHaveProperty("incrementalCache"); + expect(output.origins.default).not.toHaveProperty("tagCache"); + expect(output.additionalProps?.cacheFunction).toMatchObject({ + incrementalCache: "dummy", + tagCache: "dummy", + }); + }); + + test("uses adapter cache defaults in generated metadata", async () => { + const opts = createMockBuildOpts(); + const output = await buildOpenNextOutput(opts, { + cache: { + incrementalCache: "custom-incremental", + tagCache: "custom-tags", + }, + }); + + expect(output.additionalProps?.cacheFunction).toMatchObject({ + incrementalCache: "custom-incremental", + tagCache: "custom-tags", + }); + }); }); describe("generateOutput (legacy wrapper)", () => { diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index f5d0b45c..34d4a50d 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -8,6 +8,7 @@ import type { ExternalMiddlewareConfig, FunctionOptions, LazyLoadedOverride, + OpenNextConfig, OverrideOptions, } from "@/types/open-next"; @@ -37,6 +38,9 @@ type OpenNextECSOrigin = { type CommonOverride = { queue: string; +}; + +type CacheFunction = BaseFunction & { incrementalCache: string; tagCache: string; }; @@ -88,7 +92,7 @@ export interface OpenNextOutput { initializationFunction?: BaseFunction; warmer?: BaseFunction; revalidationFunction?: BaseFunction; - cacheFunction?: BaseFunction; + cacheFunction?: CacheFunction; }; } @@ -169,17 +173,26 @@ async function extractOverrideFn(override?: DefaultOverrideOptions) { } async function extractCommonOverride(override?: OverrideOptions) { - if (!override) { - return { - queue: "sqs", - incrementalCache: "s3", - tagCache: "dynamodb", - }; - } - const queue = await extractOverrideName("sqs", override.queue); - const incrementalCache = await extractOverrideName("s3", override.incrementalCache); - const tagCache = await extractOverrideName("dynamodb", override.tagCache); - return { queue, incrementalCache, tagCache }; + return { queue: await extractOverrideName("sqs", override?.queue) }; +} + +/** + * Resolves provider names for the dedicated cache function. + * + * @param cacheHandler User cache-handler configuration. + * @param defaults Adapter defaults for the cache bundle. + * @return Effective incremental-cache and tag-cache provider names. + */ +async function extractCacheOverride( + cacheHandler?: OpenNextConfig["cacheHandler"], + defaults?: DefaultOverrides +): Promise> { + const incrementalCache = await extractOverrideName( + "s3", + cacheHandler?.incrementalCache ?? defaults?.incrementalCache + ); + const tagCache = await extractOverrideName("dynamodb", cacheHandler?.tagCache ?? defaults?.tagCache); + return { incrementalCache, tagCache }; } function prefixPattern(basePath: string) { @@ -400,6 +413,7 @@ export async function buildOpenNextOutput( : { handler: indexHandler, bundle: ".open-next/cache-function", + ...(await extractCacheOverride(config.cacheHandler, bundleDefaults?.cache)), }, }, }; diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 6fe5e094..250142b4 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -128,8 +128,6 @@ export async function buildExternalNodeMiddleware( overrides: { wrapper: override("wrapper"), converter: override("converter"), - tagCache: override("tagCache"), - incrementalCache: override("incrementalCache"), cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), @@ -138,9 +136,6 @@ export async function buildExternalNodeMiddleware( defaultOverrides: { wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/node.js", converter: defaultOverrides?.converter ?? "@opennextjs/core/overrides/converters/node.js", - tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", - incrementalCache: - defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", cache: defaultOverrides?.cache ?? "@opennextjs/core/overrides/cache/dummy.js", queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", originResolver: diff --git a/packages/core/src/core/createMainHandler.ts b/packages/core/src/core/createMainHandler.ts index eb6129b3..a158742d 100644 --- a/packages/core/src/core/createMainHandler.ts +++ b/packages/core/src/core/createMainHandler.ts @@ -9,10 +9,8 @@ import { resolveCache, resolveCdnInvalidation, resolveConverter, - resolveIncrementalCache, resolveProxyRequest, resolveQueue, - resolveTagCache, resolveWrapper, } from "./resolve"; @@ -32,10 +30,6 @@ export async function createMainHandler() { // Default queue globalThis.queue = await resolveQueue(thisFunction.override?.queue); - globalThis.incrementalCache = await resolveIncrementalCache(thisFunction.override?.incrementalCache); - - globalThis.tagCache = await resolveTagCache(thisFunction.override?.tagCache); - if (config.middleware?.external !== true) { globalThis.assetResolver = await resolveAssetResolver( globalThis.openNextConfig.middleware?.assetResolver diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 9027033f..88b96ce9 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -44,7 +44,9 @@ export async function resolveWrapper< * @returns * @__PURE__ */ -export async function resolveTagCache(tagCache: OverrideOptions["tagCache"]): Promise { +export async function resolveTagCache( + tagCache: RemoveUndefined["tagCache"] +): Promise { if (typeof tagCache === "function") { return tagCache(); } @@ -72,7 +74,9 @@ export async function resolveQueue(queue: OverrideOptions["queue"]) { * @returns * @__PURE__ */ -export async function resolveIncrementalCache(incrementalCache: OverrideOptions["incrementalCache"]) { +export async function resolveIncrementalCache( + incrementalCache: RemoveUndefined["incrementalCache"] +) { if (typeof incrementalCache === "function") { return incrementalCache(); } @@ -162,12 +166,9 @@ export async function resolveCdnInvalidation(cdnInvalidation: OverrideOptions["c * Resolves the configured dedicated cache transport. * * @param cache Cache transport override. - * @return The configured transport, or undefined when dedicated caching is not enabled. + * @return The configured transport, or the dummy cache when no override is configured. */ -export async function resolveCache(cache: OverrideOptions["cache"]): Promise { - if (cache === undefined) { - return undefined; - } +export async function resolveCache(cache: OverrideOptions["cache"]): Promise { if (typeof cache === "function") { return cache(); } diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index fa340e29..e484e519 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -4,7 +4,6 @@ import { NextConfig, PrerenderManifest } from "@/config/index"; import type { InternalEvent, InternalResult, MiddlewareEvent, PartialResult } from "@/types/open-next"; import type { CacheValue } from "@/types/overrides"; import { isBinaryContentType } from "@/utils/binary"; -import { getTagsFromValue, hasBeenRevalidated } from "@/utils/cache"; import { emptyReadableStream, toReadableStream } from "@/utils/stream"; import { debug, error } from "../../adapters/logger"; @@ -340,24 +339,12 @@ export async function cacheInterceptor( } else if (localizedPath === "") { pathToUse = "/index"; } - const cachedData = await (globalThis.cache ?? globalThis.incrementalCache).get(pathToUse); + const cachedData = await globalThis.cache.get(pathToUse); debug("cached data in interceptor", cachedData); if (!cachedData?.value) { return event; } - // We need to check the tag cache now - if (cachedData.value?.type === "app" || cachedData.value?.type === "route") { - const tags = getTagsFromValue(cachedData.value); - - const _hasBeenRevalidated = cachedData.shouldBypassTagCache - ? false - : await hasBeenRevalidated(localizedPath, tags, cachedData); - - if (_hasBeenRevalidated) { - return event; - } - } const host = event.headers.host; switch (cachedData?.value?.type) { case "app": diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index 77c2f954..352d97ee 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -18,9 +18,10 @@ function ensureResponseOk(response: Pick, operation: const fetchCache: Cache = { name: "fetch-cache", - get: async (key, cacheType) => { + get: async (key, cacheType, additionalTags) => { const query: Record = {}; if (cacheType) query.type = cacheType; + if (additionalTags && additionalTags.length > 0) query.tags = additionalTags.join(","); const queryString = Object.keys(query).length > 0 ? `?${new URLSearchParams(query).toString()}` : ""; const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; const response = await fetch(url, { method: "GET" }); @@ -32,10 +33,13 @@ const fetchCache: Cache = { // oxlint-disable-next-line @typescript-eslint/no-explicit-any return parseCacheGetResponse(headers, bodyText) as any; }, - set: async (key, value, cacheType) => { + set: async (key, value, cacheType, additionalTags) => { // The cache type has to be forwarded: incremental caches may key entries on it, // writing without it would store the entry where `get` does not look for it. - const queryString = cacheType ? `?type=${cacheType}` : ""; + const query = new URLSearchParams(); + if (cacheType) query.set("type", cacheType); + if (additionalTags && additionalTags.length > 0) query.set("tags", additionalTags.join(",")); + const queryString = query.size > 0 ? `?${query.toString()}` : ""; const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; const response = await fetch(url, { method: "PUT", diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index 167e2751..c906ab68 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -38,17 +38,20 @@ function ensureResultOk(result: InternalResult, operation: string): void { const localCache: Cache = { name: "local-cache", - get: async (key, cacheType) => { + get: async (key, cacheType, additionalTags) => { const h = (await getHandler())!; const encodedKey = encodeURIComponent(key); const url = `https://on/cache/${encodedKey}`; + const query: Record = {}; + if (cacheType) query.type = cacheType; + if (additionalTags && additionalTags.length > 0) query.tags = additionalTags.join(","); const event: InternalEvent = { type: "core", method: "GET", rawPath: `/cache/${encodedKey}`, url, headers: {}, - query: cacheType ? { type: cacheType } : {}, + query, cookies: {}, remoteAddress: "127.0.0.1", }; @@ -57,7 +60,7 @@ const localCache: Cache = { // oxlint-disable-next-line @typescript-eslint/no-explicit-any return parseCacheGetResponse(result.headers, bodyText) as any; }, - set: async (key, value, cacheType) => { + set: async (key, value, cacheType, additionalTags) => { const h = (await getHandler())!; const encodedKey = encodeURIComponent(key); const url = `https://on/cache/${encodedKey}`; @@ -65,6 +68,7 @@ const localCache: Cache = { // writing without it would store the entry where `get` does not look for it. const query: Record = {}; if (cacheType) query.type = cacheType; + if (additionalTags && additionalTags.length > 0) query.tags = additionalTags.join(","); const event: InternalEvent = { type: "core", method: "PUT", diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 87ed3f61..6992d5ad 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -8,12 +8,14 @@ import type { Plugin } from "esbuild"; import type { DefaultOverrideOptions, IncludedImageLoader, + IncludedIncrementalCache, IncludedOriginResolver, + IncludedTagCache, IncludedWarmer, LazyLoadedOverride, OverrideOptions, } from "@/types/open-next"; -import type { ImageLoader, OriginResolver, Warmer } from "@/types/overrides"; +import type { ImageLoader, IncrementalCache, OriginResolver, TagCache, Warmer } from "@/types/overrides"; import logger from "../logger.js"; import { getCrossPlatformPathRegex } from "../utils/regex.js"; @@ -24,9 +26,9 @@ export interface IPluginSettings { wrapper?: DefaultOverrideOptions["wrapper"]; // oxlint-disable-next-line @typescript-eslint/no-explicit-any - generic overrides for flexibility converter?: DefaultOverrideOptions["converter"]; - tagCache?: OverrideOptions["tagCache"]; + tagCache?: IncludedTagCache | LazyLoadedOverride; queue?: OverrideOptions["queue"]; - incrementalCache?: OverrideOptions["incrementalCache"]; + incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; imageLoader?: LazyLoadedOverride | IncludedImageLoader; originResolver?: LazyLoadedOverride | IncludedOriginResolver; warmer?: LazyLoadedOverride | IncludedWarmer; diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 4694e9ff..552bc664 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -75,15 +75,15 @@ declare global { // Needed in the cache adapter /** * The cache adapter for incremental static regeneration. - * Only available in main functions and in the external middleware when `enableCacheInterception` is `true`. - * Defined in `createMainHandler` and in `adapters/middleware.ts`. + * Only set in the cache handler function (cache-adapter.ts) from `cacheHandler` config. + * Not available in main functions or middleware anymore — use `globalThis.cache` instead. */ var incrementalCache: IncrementalCache; /** * The cache adapter for the tag cache. - * Only available in main functions and in the external middleware when `enableCacheInterception` is `true`. - * Defined in `createMainHandler` and in `adapters/middleware.ts`. + * Only set in the cache handler function (cache-adapter.ts) from `cacheHandler` config. + * Not available in main functions or middleware anymore — use `globalThis.cache` instead. */ var tagCache: TagCache; @@ -201,7 +201,7 @@ declare global { * Only available in main functions. * Defined in `createMainHandler`. */ - var cache: Cache | undefined; + var cache: Cache; /** * The function that is used when resolving external rewrite requests. diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index fe65d04b..e1850db9 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -237,18 +237,6 @@ export interface DefaultOverrideOptions< } export interface OverrideOptions extends DefaultOverrideOptions { - /** - * Add possibility to override the default s3 cache. Used for fetch cache and html/rsc/json cache. - * @default "s3" - */ - incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; - - /** - * Add possibility to override the default tag cache. Used for revalidateTags and revalidatePath. - * @default "dynamodb" - */ - tagCache?: IncludedTagCache | LazyLoadedOverride; - /** * Add possibility to override the default queue. Used for isr. * @default "sqs" diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 64a6f5e9..0de795e6 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -285,12 +285,14 @@ export type ProxyExternalRequest = BaseOverride & { export type Cache = BaseOverride & { get( key: string, - cacheType?: CacheType + cacheType?: CacheType, + additionalTags?: string[] ): Promise> | null>; set( key: string, value: CacheValue, - isFetch?: CacheType + isFetch?: CacheType, + additionalTags?: string[] ): Promise; delete(key: string): Promise; revalidateTags(tags: string[]): Promise; diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index 090bc247..98f14441 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -2,6 +2,7 @@ import type { CacheEntryType, CacheValue, OriginalTagCacheWriteInput, + TagCache, WithLastModified, } from "@/types/overrides"; @@ -10,7 +11,8 @@ import { debug } from "../adapters/logger"; export async function hasBeenRevalidated( key: string, tags: string[], - cacheEntry: WithLastModified> + cacheEntry: WithLastModified>, + tagCache: TagCache = globalThis.tagCache ): Promise { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; @@ -24,11 +26,11 @@ export async function hasBeenRevalidated( return false; } const lastModified = cacheEntry.lastModified ?? Date.now(); - if (globalThis.tagCache.mode === "nextMode") { - return tags.length === 0 ? false : await globalThis.tagCache.hasBeenRevalidated(tags, lastModified); + if (tagCache.mode === "nextMode") { + return tags.length === 0 ? false : await tagCache.hasBeenRevalidated(tags, lastModified); } // TODO: refactor this, we should introduce a new method in the tagCache interface so that both implementations use hasBeenRevalidated - const _lastModified = await globalThis.tagCache.getLastModified(key, lastModified); + const _lastModified = await tagCache.getLastModified(key, lastModified); return _lastModified === -1; } @@ -56,7 +58,10 @@ function getTagKey(tag: string | OriginalTagCacheWriteInput): string { }); } -export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): Promise { +export async function writeTags( + tags: (string | OriginalTagCacheWriteInput)[], + tagCache: TagCache = globalThis.tagCache +): Promise { const store = globalThis.__openNextAls.getStore(); debug("Writing tags", tags, store); if (!store || globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -75,8 +80,7 @@ export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): if (tagsToWrite.length === 0) { return; } - // Here we know that we have the correct type // oxlint-disable-next-line @typescript-eslint/no-explicit-any - writeTags accepts a union type that typescript cannot infer correctly - await globalThis.tagCache.writeTags(tagsToWrite as any); + await tagCache.writeTags(tagsToWrite as any); } diff --git a/packages/tests-unit/tests/adapters/aws-adapter.test.ts b/packages/tests-unit/tests/adapters/aws-adapter.test.ts new file mode 100644 index 00000000..c9f96932 --- /dev/null +++ b/packages/tests-unit/tests/adapters/aws-adapter.test.ts @@ -0,0 +1,33 @@ +import createAwsAdapterOptions from "@opennextjs/aws/adapter.js"; +import { describe, expect, it, vi } from "vitest"; + +const { buildAdapter } = vi.hoisted(() => ({ + buildAdapter: vi.fn((callback) => callback), +})); + +vi.mock("@opennextjs/core/build/adapter.js", () => ({ buildAdapter })); + +describe("AWS adapter", () => { + it("routes server caching through the dedicated AWS cache function", () => { + const createOptions = createAwsAdapterOptions as unknown as ( + config: unknown, + buildOptions: unknown + ) => { + defaultOverrides: Record>; + }; + const { defaultOverrides } = createOptions({}, {}); + + expect(defaultOverrides.server).toMatchObject({ + cache: "@opennextjs/core/overrides/cache/local.js", + }); + expect(defaultOverrides.middleware).toMatchObject({ + cache: "@opennextjs/core/overrides/cache/local.js", + }); + expect(defaultOverrides.cache).toEqual({ + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + }); + }); +}); diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 4780e7ad..12a13a8a 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -1,61 +1,88 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { handler } from "@opennextjs/core/adapters/cache-handler"; -import type { InternalEvent } from "@opennextjs/core/types/open-next"; -import { toReadableStream } from "@opennextjs/core/utils/stream"; -import { beforeEach, describe, expect, test, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - incrementalCache: { - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - }, - tagCache: { - mode: "nextMode" as const, - getPathsByTags: vi.fn().mockResolvedValue([]), - writeTags: vi.fn(), - }, - cdnInvalidationHandler: { - invalidatePaths: vi.fn(), - }, +import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; +import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; + +const mockResolveIncrementalCache = vi.hoisted(() => vi.fn()); +const mockResolveTagCache = vi.hoisted(() => vi.fn()); +const mockResolveCdnInvalidation = vi.hoisted(() => vi.fn()); + +const mockIncrementalCache = vi.hoisted(() => ({ + name: "mock", + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), })); -vi.mock("@opennextjs/core/core/createGenericHandler", () => ({ - createGenericHandler: vi.fn(async ({ handler }) => handler), +const mockTagCache = vi.hoisted(() => ({ + name: "mock", + mode: "original", + getByTag: vi.fn(), + getByPath: vi.fn(), + getLastModified: vi.fn(), + writeTags: vi.fn(), + hasBeenRevalidated: vi.fn(), + getPathsByTags: undefined as Mock | undefined, })); -vi.mock("@opennextjs/core/core/resolve", () => ({ - resolveIncrementalCache: vi.fn().mockResolvedValue(mocks.incrementalCache), - resolveTagCache: vi.fn().mockResolvedValue(mocks.tagCache), - resolveCdnInvalidation: vi.fn().mockResolvedValue(mocks.cdnInvalidationHandler), +const mockCdnInvalidationHandler = vi.hoisted(() => ({ + name: "mock", + invalidatePaths: vi.fn(), })); -vi.mock("@opennextjs/core/utils/cache", () => ({ - writeTags: vi.fn(), +vi.mock("@opennextjs/core/core/resolve", () => ({ + resolveIncrementalCache: mockResolveIncrementalCache, + resolveTagCache: mockResolveTagCache, + resolveCdnInvalidation: mockResolveCdnInvalidation, })); -function createEvent(method: string, rawPath: string, body?: string): InternalEvent { +function createEvent(overrides: Partial = {}): InternalEvent { return { type: "core", - method, - rawPath, - url: `https://cache.test${rawPath}`, + method: "GET", + rawPath: "/cache/test-key", + url: "https://on/cache/test-key", headers: {}, query: {}, cookies: {}, remoteAddress: "127.0.0.1", - body: body === undefined ? undefined : toReadableStream(body), + ...overrides, }; } -describe("cache adapter stream bodies", () => { +async function runHandler(event: InternalEvent): Promise { + return globalThis.__openNextAls.run( + { + requestId: "test-request", + pendingPromiseRunner: { + withResolvers: () => ({ + resolve: vi.fn(), + promise: Promise.resolve(), + }), + }, + isISRRevalidation: false, + writtenTags: new Set(), + }, + () => handler(event) + ); +} + +describe("cache-handler", () => { beforeEach(() => { vi.clearAllMocks(); - globalThis.openNextConfig = { default: {} }; + globalThis.__openNextAls = new AsyncLocalStorage(); + // @ts-ignore + globalThis.openNextConfig = { dangerous: {} } as Partial; + mockResolveIncrementalCache.mockResolvedValue(mockIncrementalCache); + mockResolveTagCache.mockResolvedValue(mockTagCache); + mockResolveCdnInvalidation.mockResolvedValue(mockCdnInvalidationHandler); + mockTagCache.mode = "original"; + mockTagCache.getPathsByTags = undefined; }); - test("preserves existing request context storage", async () => { + it("preserves existing request context storage", async () => { const requestStorage = new AsyncLocalStorage(); globalThis.__openNextAls = requestStorage; vi.resetModules(); @@ -65,31 +92,655 @@ describe("cache adapter stream bodies", () => { expect(globalThis.__openNextAls).toBe(requestStorage); }); - test("stores a value from a streamed request body", async () => { - const value = { type: "route", body: "content" }; - const result = await handler(createEvent("PUT", "/cache/key", JSON.stringify({ value }))); + describe("routing", () => { + it("should return 404 for non-cache paths", async () => { + const event = createEvent({ rawPath: "/other/path" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(404); + const body = await fromReadableStream(result.body); + expect(body).toContain("Not Found"); + }); + + it("should return 400 for missing cache key", async () => { + const event = createEvent({ rawPath: "/cache/" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + const body = await fromReadableStream(result.body); + expect(body).toContain("Missing cache key"); + }); + + it("should return 405 for unknown method", async () => { + const event = createEvent({ method: "PATCH" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(405); + const body = await fromReadableStream(result.body); + expect(body).toContain("Method Not Allowed"); + }); + }); + + describe("GET /cache/:key", () => { + it("should return 404 when cache entry is not found", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-found"]).toBe("false"); + }); + + it("should return 404 when cache entry value is missing", async () => { + mockIncrementalCache.get.mockResolvedValue({}); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-found"]).toBe("false"); + }); + + it("should return 200 with route cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-found"]).toBe("true"); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("route"); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + const body = await fromReadableStream(result.body); + expect(body).toBe("route-body"); + }); + + it("should return 200 with page cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "page", html: "", json: { data: 1 } }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("page"); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed).toEqual({ html: "", json: { data: 1 } }); + }); + + it("should return 200 with app cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "app", + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("app"); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed.html).toBe(""); + expect(parsed.rsc).toBe("rsc-data"); + expect(parsed.segmentData).toEqual({ seg1: "data1" }); + }); + + it("should return 200 with redirect cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "redirect", props: { destination: "/new" } }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("redirect"); + }); + + it("should return 200 with fetch cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + kind: "FETCH", + data: { + headers: { "content-type": "text/plain" }, + body: "fetch-body", + url: "https://example.com", + status: 200, + }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("fetch"); + expect(result.headers["x-opennext-cache-fetch-kind"]).toBe("FETCH"); + expect(result.headers["x-opennext-cache-fetch-data-url"]).toBe("https://example.com"); + const body = await fromReadableStream(result.body); + expect(body).toBe("fetch-body"); + }); + + it("should use ?type=fetch when query param is provided", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + const event = createEvent({ query: { type: "fetch" } }); + + await runHandler(event); + + expect(mockIncrementalCache.get).toHaveBeenCalledWith("test-key", "fetch"); + }); + + it("should use ?type=cache by default", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + await runHandler(createEvent()); + + expect(mockIncrementalCache.get).toHaveBeenCalledWith("test-key", "cache"); + }); + + it("should return 500 when incremental cache throws", async () => { + mockIncrementalCache.get.mockRejectedValue(new Error("cache error")); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(500); + }); + }); + + describe("tag revalidation in GET", () => { + it("should check the cache key in original mode when there are no tags", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).toHaveBeenCalledWith("test-key", 1000); + }); + + it("should return 404 when an untagged original-mode entry has been revalidated", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(-1); + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should check tag revalidation in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.hasBeenRevalidated).toHaveBeenCalledWith(["tag1"], 1000); + expect(result.statusCode).toBe(200); + }); + + it("should return 404 when tags have been revalidated in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should check last modified in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.getLastModified).toHaveBeenCalledWith("test-key", 1000); + expect(result.statusCode).toBe(200); + }); + + it("should return 404 when tags have been revalidated in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(-1); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should return 404 when a fetch entry's owning path has been revalidated", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValueOnce(1000).mockResolvedValueOnce(-1); + mockIncrementalCache.get.mockResolvedValue({ + value: { + kind: "FETCH", + data: { headers: {}, body: "data", url: "https://example.com" }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent({ query: { type: "fetch", tags: "_N_T_/some-path" } })); + + expect(mockTagCache.getLastModified).toHaveBeenNthCalledWith(1, "test-key", 1000); + expect(mockTagCache.getLastModified).toHaveBeenNthCalledWith(2, "some-path", 1000); + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should skip tag revalidation when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "internal-tag" } }, + }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers).not.toHaveProperty("x-opennext-cache-header-x-next-cache-tags"); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); + }); + + it("should skip tag revalidation when disableTagCache is true", async () => { + // @ts-ignore + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + } as Partial; + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); + }); + }); + + describe("PUT /cache/:key", () => { + it("should return 400 when body is missing", async () => { + const result = await runHandler(createEvent({ method: "PUT" })); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is empty", async () => { + const event = createEvent({ method: "PUT", body: toReadableStream("") }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when value is missing in body", async () => { + const event = createEvent({ method: "PUT", body: toReadableStream(JSON.stringify({})) }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is invalid JSON", async () => { + const event = createEvent({ method: "PUT", body: toReadableStream("invalid json") }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should set cache entry and return 200", async () => { + const value = { type: "route", body: "content" }; + const event = createEvent({ + method: "PUT", + body: toReadableStream(JSON.stringify({ value })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockIncrementalCache.set).toHaveBeenCalledWith("test-key", value, "cache"); + const body = await fromReadableStream(result.body); + expect(JSON.parse(body)).toEqual({ ok: true }); + }); + + it("should write derived tags for non-nextMode tag caches", async () => { + mockTagCache.getByPath.mockResolvedValue([]); + mockTagCache.mode = "original"; + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: toReadableStream(JSON.stringify({ value })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should write additional tags supplied with a fetch entry", async () => { + const event = createEvent({ + method: "PUT", + query: { type: "fetch", tags: "tag1,tag2" }, + body: toReadableStream( + JSON.stringify({ + value: { kind: "FETCH", data: { headers: {}, body: "body", url: "url" } }, + }) + ), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { path: "test-key", tag: "tag1", revalidatedAt: 1 }, + { path: "test-key", tag: "tag2", revalidatedAt: 1 }, + ]); + }); + + it("should write derived tags without an ambient request context", async () => { + // The cache handler runs as its own function: nothing establishes an OpenNext request + // context for it, and `writeTags` gives up when there is none. + mockTagCache.getByPath.mockResolvedValue([]); + mockTagCache.mode = "original"; + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: toReadableStream(JSON.stringify({ value })), + }); + + await handler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should skip tag writing in nextMode", async () => { + mockTagCache.mode = "nextMode"; + const event = createEvent({ + method: "PUT", + body: toReadableStream(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should skip tag writing when disableTagCache is true", async () => { + // @ts-ignore + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + } as Partial; + const event = createEvent({ + method: "PUT", + body: toReadableStream(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should skip writing tags that are already stored", async () => { + mockTagCache.getByPath.mockResolvedValue(["tag1", "tag2"]); - expect(result.statusCode).toBe(200); - expect(mocks.incrementalCache.set).toHaveBeenCalledWith("key", value, "cache"); + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: toReadableStream(JSON.stringify({ value })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should return 500 when set fails", async () => { + mockIncrementalCache.set.mockRejectedValue(new Error("set error")); + const event = createEvent({ + method: "PUT", + body: toReadableStream(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(500); + }); }); - test("revalidates tags from a streamed request body", async () => { - const result = await handler( - createEvent("POST", "/cache/revalidate-tags", JSON.stringify({ tags: ["one", "two"] })) - ); + describe("DELETE /cache/:key", () => { + it("should delete cache entry and return 200", async () => { + const result = await runHandler(createEvent({ method: "DELETE" })); - expect(result.statusCode).toBe(200); - expect(mocks.tagCache.getPathsByTags).toHaveBeenCalledWith(["one", "two"]); + expect(result.statusCode).toBe(200); + expect(mockIncrementalCache.delete).toHaveBeenCalledWith("test-key"); + const body = await fromReadableStream(result.body); + expect(JSON.parse(body)).toEqual({ ok: true }); + }); + + it("should return 500 when delete fails", async () => { + mockIncrementalCache.delete.mockRejectedValue(new Error("delete error")); + + const result = await runHandler(createEvent({ method: "DELETE" })); + + expect(result.statusCode).toBe(500); + }); }); - test("rejects non-string tags", async () => { - const result = await handler( - createEvent("POST", "/cache/revalidate-tags", JSON.stringify({ tags: ["one", 2] })) - ); + describe("POST /cache/revalidate-tags", () => { + it("should return 400 when body is missing", async () => { + const event = createEvent({ rawPath: "/cache/revalidate-tags", method: "POST" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is empty", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(""), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when tags array is missing", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({})), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when tags are empty array", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: [] })), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should reject non-string tags", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1", 2] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should return 400 when body is invalid JSON", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream("not json"), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should revalidate tags in nextMode without getPathsByTags", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.getPathsByTags = undefined; + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed.revalidated).toEqual(["tag1"]); + }); + + it("should revalidate tags in nextMode with getPathsByTags and invalidate CDN", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.getPathsByTags = vi.fn().mockResolvedValue(["/path1"]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + expect(mockCdnInvalidationHandler.invalidatePaths).toHaveBeenCalledWith([ + expect.objectContaining({ initialPath: "/path1", rawPath: "/path1" }), + ]); + }); + + it("should revalidate tags in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + mockTagCache.getByPath.mockResolvedValue([]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getByTag).toHaveBeenCalledWith("tag1"); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should invalidate CDN for soft tags in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/some-path"]); + mockTagCache.getByPath.mockResolvedValue([]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["_N_T_//some-path"] })), + }); + + await runHandler(event); + + expect(mockCdnInvalidationHandler.invalidatePaths).toHaveBeenCalled(); + }); + + it("should return 500 when revalidation fails", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockRejectedValue(new Error("tag error")); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); - expect(result.statusCode).toBe(400); - expect(mocks.tagCache.getPathsByTags).not.toHaveBeenCalled(); - expect(mocks.tagCache.writeTags).not.toHaveBeenCalled(); - expect(mocks.cdnInvalidationHandler.invalidatePaths).not.toHaveBeenCalled(); + expect(result.statusCode).toBe(500); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index c79589db..e5b78f67 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -1,5 +1,30 @@ -import Cache, { SOFT_TAG_PREFIX } from "@opennextjs/core/adapters/cache.js"; -import { type Mock, vi } from "vitest"; +import Cache from "@opennextjs/core/adapters/cache.js"; +import { vi } from "vitest"; + +const cache = { + name: "mock", + get: vi.fn().mockResolvedValue({ + value: { + type: "route", + body: "{}", + }, + lastModified: Date.now(), + }), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), +}; +globalThis.cache = cache; + +globalThis.__openNextAls = { + getStore: vi.fn().mockReturnValue({ + pendingPromiseRunner: { + withResolvers: vi.fn().mockReturnValue({ + resolve: vi.fn(), + }), + }, + }), +}; declare global { var openNextConfig: { @@ -9,66 +34,16 @@ declare global { } describe("CacheHandler", () => { - let cache: Cache; + let instance: Cache; vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); const getFetchCacheSpy = vi.spyOn(Cache.prototype, "getFetchCache"); const getIncrementalCache = vi.spyOn(Cache.prototype, "getIncrementalCache"); - const incrementalCache = { - name: "mock", - get: vi.fn().mockResolvedValue({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - }), - set: vi.fn(), - delete: vi.fn(), - }; - globalThis.incrementalCache = incrementalCache; - const cacheTransport = { - name: "transport", - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - revalidateTags: vi.fn(), - }; - - const tagCache = { - name: "mock", - mode: "original", - hasBeenRevalidated: vi.fn(), - getByTag: vi.fn(), - getByPath: vi.fn(), - getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), - writeTags: vi.fn(), - getPathsByTags: undefined as Mock | undefined, - }; - globalThis.tagCache = tagCache; - - const invalidateCdnHandler = { - name: "mock", - invalidatePaths: vi.fn(), - }; - globalThis.cdnInvalidationHandler = invalidateCdnHandler; - - globalThis.__openNextAls = { - getStore: vi.fn().mockReturnValue({ - pendingPromiseRunner: { - withResolvers: vi.fn().mockReturnValue({ - resolve: vi.fn(), - }), - }, - writtenTags: new Set(), - }), - }; - beforeEach(() => { vi.clearAllMocks(); - cache = new Cache(); + instance = new Cache(); globalThis.openNextConfig = { dangerous: { @@ -76,55 +51,13 @@ describe("CacheHandler", () => { }, }; globalThis.isNextAfter15 = false; - globalThis.cache = undefined; - tagCache.mode = "original"; - tagCache.getPathsByTags = undefined; - }); - - describe("dedicated cache transport", () => { - beforeEach(() => { - globalThis.cache = cacheTransport; - }); - - it("uses the transport for reads", async () => { - cacheTransport.get.mockResolvedValueOnce({ - value: { type: "route", body: "{}" }, - lastModified: Date.now(), - }); - - await cache.get("key"); - - expect(cacheTransport.get).toHaveBeenCalledWith("key", "cache"); - expect(incrementalCache.get).not.toHaveBeenCalled(); - }); - - it("uses the transport for writes and deletes", async () => { - await cache.set("key", { kind: "REDIRECT", props: {} }); - await cache.set("deleted", undefined); - - expect(cacheTransport.set).toHaveBeenCalledWith( - "key", - expect.objectContaining({ type: "redirect" }), - "cache" - ); - expect(cacheTransport.delete).toHaveBeenCalledWith("deleted"); - expect(incrementalCache.set).not.toHaveBeenCalled(); - expect(incrementalCache.delete).not.toHaveBeenCalled(); - }); - - it("uses the transport for tag revalidation", async () => { - await cache.revalidateTag(["one", "two"]); - - expect(cacheTransport.revalidateTags).toHaveBeenCalledWith(["one", "two"]); - expect(tagCache.getByTag).not.toHaveBeenCalled(); - }); }); describe("get", () => { it("Should return null for cache miss", async () => { - incrementalCache.get.mockResolvedValueOnce({}); + cache.get.mockResolvedValueOnce({}); - const result = await cache.get("key"); + const result = await instance.get("key"); expect(result).toBeNull(); }); @@ -135,68 +68,51 @@ describe("CacheHandler", () => { }); it("Should return null when incremental cache is disabled", async () => { - const result = await cache.get("key"); + const result = await instance.get("key"); expect(result).toBeNull(); }); it("Should not set cache when incremental cache is disabled", async () => { - globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - - await cache.set("key", { kind: "REDIRECT", props: {} }); + await instance.set("key", { kind: "REDIRECT", props: {} }); - expect(incrementalCache.set).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); }); it("Should not delete cache when incremental cache is disabled", async () => { - globalThis.openNextConfig.dangerous.disableIncrementalCache = true; + await instance.set("key", undefined); - await cache.set("key", undefined); - - expect(incrementalCache.delete).not.toHaveBeenCalled(); + expect(cache.delete).not.toHaveBeenCalled(); }); }); describe("fetch cache", () => { it("Should retrieve cache from fetch cache when hint is fetch (next14)", async () => { - await cache.get("key", { kindHint: "fetch" }); + await instance.get("key", { kindHint: "fetch" }); expect(getFetchCacheSpy).toHaveBeenCalled(); }); describe("next15", () => { it("Should retrieve cache from fetch cache when hint is fetch", async () => { - await cache.get("key", { kind: "FETCH" }); + await instance.get("key", { kind: "FETCH" }); expect(getFetchCacheSpy).toHaveBeenCalled(); }); - it("Should return null when tag cache last modified is -1", async () => { - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cache.get("key", { kind: "FETCH" }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(result).toBeNull(); - }); + it("Should return null when fetch cache entry is not found", async () => { + cache.get.mockResolvedValueOnce(null); - it("Should return null with nextMode tag cache that has been revalidated", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); + const result = await instance.get("key", { kind: "FETCH" }); - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag"], - }); expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); expect(result).toBeNull(); }); it("Should return null when incremental cache throws", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Error retrieving cache")); + cache.get.mockRejectedValueOnce(new Error("Error retrieving cache")); - const result = await cache.get("key", { kind: "FETCH" }); + const result = await instance.get("key", { kind: "FETCH" }); expect(getFetchCacheSpy).toHaveBeenCalled(); expect(result).toBeNull(); @@ -208,51 +124,14 @@ describe("CacheHandler", () => { it.each(["app", "pages", undefined])( "Should retrieve cache from incremental cache when hint is not fetch: %s", async (kindHint) => { - await cache.get("key", { kindHint: kindHint as any }); + await instance.get("key", { kindHint: kindHint as any }); expect(getIncrementalCache).toHaveBeenCalled(); } ); - it("Should return null when tag cache last modified is -1", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - }, - lastModified: Date.now(), - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - - it("Should return null with nextMode tag cache that has been revalidated", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - meta: { - headers: { - "x-next-cache-tags": "tag", - }, - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - it("Should return value when cache data type is route", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: "{}", @@ -260,7 +139,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -273,7 +152,7 @@ describe("CacheHandler", () => { }); it("Should return base64 encoded value when cache data type is route and content is binary", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: Buffer.from("hello").toString("base64"), @@ -286,7 +165,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -302,7 +181,7 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is app", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "", @@ -314,7 +193,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -332,7 +211,7 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is page", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "", @@ -344,7 +223,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "pages" }); + const result = await instance.get("key", { kindHint: "pages" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -361,7 +240,7 @@ describe("CacheHandler", () => { it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "", @@ -379,87 +258,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toEqual({ - value: { - kind: "APP_PAGE", - html: "", - rscData: Buffer.from("rsc-data"), - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - segmentData: new Map([ - ["segment1", Buffer.from("data1")], - ["segment2", Buffer.from("data2")], - ]), - }, - lastModified: Date.now(), - }); - }); - - it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { - globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "", - rsc: "rsc-data", - segmentData: { - segment1: "data1", - segment2: "data2", - }, - meta: { - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toEqual({ - value: { - kind: "APP_PAGE", - html: "", - rscData: Buffer.from("rsc-data"), - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - segmentData: new Map([ - ["segment1", Buffer.from("data1")], - ["segment2", Buffer.from("data2")], - ]), - }, - lastModified: Date.now(), - }); - }); - - it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { - globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "", - rsc: "rsc-data", - segmentData: { - segment1: "data1", - segment2: "data2", - }, - meta: { - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -480,14 +279,14 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is redirect", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "redirect", }, lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -499,9 +298,9 @@ describe("CacheHandler", () => { }); it("Should return null when incremental cache fails", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Error")); + cache.get.mockRejectedValueOnce(new Error("Error")); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toBeNull(); @@ -511,20 +310,20 @@ describe("CacheHandler", () => { describe("set", () => { it("Should delete cache when data is undefined", async () => { - await cache.set("key", undefined); + await instance.set("key", undefined); - expect(incrementalCache.delete).toHaveBeenCalled(); + expect(cache.delete).toHaveBeenCalled(); }); it("Should set cache when for ROUTE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "ROUTE", body: Buffer.from("{}"), status: 200, headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "route", body: "{}", meta: { status: 200, headers: {} } }, "cache" @@ -532,7 +331,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for APP_ROUTE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "APP_ROUTE", body: Buffer.from("{}"), status: 200, @@ -541,7 +340,7 @@ describe("CacheHandler", () => { }, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "route", @@ -553,27 +352,28 @@ describe("CacheHandler", () => { }); it("Should set cache when for PAGE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGE", html: "", pageData: {}, status: 200, - headers: {}, + headers: { "x-next-cache-tags": "tag1,tag2" }, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "page", html: "", json: {}, }, - "cache" + "cache", + ["tag1", "tag2"] ); }); it("Should set cache when for PAGES", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGES", html: "", pageData: "rsc", @@ -581,7 +381,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -594,7 +394,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for APP_PAGE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "APP_PAGE", html: "", rscData: Buffer.from("rsc"), @@ -602,7 +402,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -620,7 +420,7 @@ describe("CacheHandler", () => { ["segment2", Buffer.from("data2")], ]); - await cache.set("key", { + await instance.set("key", { kind: "APP_PAGE", html: "", rscData: Buffer.from("rsc"), @@ -630,7 +430,7 @@ describe("CacheHandler", () => { postponed: "postponed-data", }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -651,19 +451,26 @@ describe("CacheHandler", () => { }); it("Should set cache when for FETCH", async () => { - await cache.set("key", { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - tags: [], + await instance.set( + "key", + { + kind: "FETCH", + data: { + headers: {}, + body: "{}", + url: "https://example.com", + status: 200, + tags: [], + }, + revalidate: 60, }, - revalidate: 60, - }); + { + fetchCache: true, + tags: ["tag1", "tag2"], + } + ); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { kind: "FETCH", @@ -676,14 +483,15 @@ describe("CacheHandler", () => { }, revalidate: 60, }, - "fetch" + "fetch", + ["tag1", "tag2"] ); }); it("Should set cache when for REDIRECT", async () => { - await cache.set("key", { kind: "REDIRECT", props: {} }); + await instance.set("key", { kind: "REDIRECT", props: {} }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "redirect", @@ -694,20 +502,20 @@ describe("CacheHandler", () => { }); it("Should not set cache when for IMAGE (not implemented)", async () => { - await cache.set("key", { + await instance.set("key", { kind: "IMAGE", etag: "etag", buffer: Buffer.from("hello"), extension: "png", }); - expect(incrementalCache.set).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); }); it("Should not throw when set cache throws", async () => { - incrementalCache.set.mockRejectedValueOnce(new Error("Error")); + cache.set.mockRejectedValueOnce(new Error("Error")); - await expect(cache.set("key", { kind: "REDIRECT", props: {} })).resolves.not.toThrow(); + await expect(instance.set("key", { kind: "REDIRECT", props: {} })).resolves.not.toThrow(); }); }); @@ -716,304 +524,45 @@ describe("CacheHandler", () => { globalThis.openNextConfig.dangerous.disableTagCache = false; globalThis.openNextConfig.dangerous.disableIncrementalCache = false; }); + it("Should do nothing if disableIncrementalCache is true", async () => { globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - await cache.revalidateTag("tag"); + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); it("Should do nothing if disableTagCache is true", async () => { globalThis.openNextConfig.dangerous.disableTagCache = true; - await cache.revalidateTag("tag"); + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).not.toHaveBeenCalled(); - // Reset the config - globalThis.openNextConfig.dangerous.disableTagCache = false; + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); - it("Should call tagCache.writeTags", async () => { - tagCache.getByTag.mockResolvedValueOnce(["/path"]); - await cache.revalidateTag("tag"); + it("Should call cache.revalidateTags with single tag", async () => { + await instance.revalidateTag("tag"); - expect(tagCache.getByTag).toHaveBeenCalledWith("tag"); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "/path", - tag: "tag", - }, - ]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"]); }); - it("Should call invalidateCdnHandler.invalidatePaths", async () => { - tagCache.getByTag.mockResolvedValueOnce(["/path"]); - tagCache.getByPath.mockResolvedValueOnce([]); - await cache.revalidateTag(`${SOFT_TAG_PREFIX}path`); + it("Should call cache.revalidateTags with array of tags", async () => { + await instance.revalidateTag(["tag1", "tag2"]); - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "/path", - tag: `${SOFT_TAG_PREFIX}path`, - }, - ]); - - expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalled(); - }); - - it("Should not call invalidateCdnHandler.invalidatePaths for fetch cache key ", async () => { - tagCache.getByTag.mockResolvedValueOnce(["123456"]); - await cache.revalidateTag("tag"); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "123456", - tag: "tag", - }, - ]); - - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); }); - it("Should only call writeTags for nextMode", async () => { - tagCache.mode = "nextMode"; - await cache.revalidateTag(["tag1", "tag2"]); + it("Should not call cache.revalidateTags when tags array is empty", async () => { + await instance.revalidateTag([]); - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); - it("Should not call writeTags when the tag list is empty for nextMode", async () => { - tagCache.mode = "nextMode"; - await cache.revalidateTag([]); - - expect(tagCache.writeTags).not.toHaveBeenCalled(); - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); - }); - - it("Should call writeTags and invalidateCdnHandler.invalidatePaths for nextMode that supports getPathsByTags", async () => { - tagCache.mode = "nextMode"; - tagCache.getPathsByTags = vi.fn().mockResolvedValueOnce(["/path"]); - await cache.revalidateTag("tag"); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag"]); - expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalledWith([ - { - initialPath: "/path", - rawPath: "/path", - resolvedRoutes: [ - { - type: "app", - route: "/path", - isFallback: false, - }, - ], - }, - ]); - }); - }); - - describe("shouldBypassTagCache", () => { - describe("fetch cache", () => { - it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value).toEqual({ - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }); - }); - - it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); + it("Should not throw when revalidateTags fails", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("Error")); - it("Should not bypass tag cache validation when shouldBypassTagCache is undefined", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - // shouldBypassTagCache not set - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - - it("Should bypass path validation when shouldBypassTagCache is true for soft tags", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { - kind: "FETCH", - softTags: [`${SOFT_TAG_PREFIX}path`], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - }); - - describe("incremental cache", () => { - it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value?.kind).toEqual("APP_ROUTE"); - }); - - it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - meta: { headers: { "x-next-cache-tags": "tag" } }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - - it("Should return null when tag cache indicates revalidation and shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - meta: { headers: { "x-next-cache-tags": "tag" } }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - - it("Should return value when tag cache indicates revalidation but shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value?.kind).toEqual("APP_ROUTE"); - }); + await expect(instance.revalidateTag("tag")).resolves.not.toThrow(); }); }); }); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 6e0ce2b2..e126bcde 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -2,66 +2,41 @@ import ComposableCache from "@opennextjs/core/adapters/composable-cache"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { vi } from "vitest"; +const cache = { + name: "mock", + get: vi.fn().mockResolvedValue({ + value: { + type: "route", + body: "{}", + tags: ["tag1", "tag2"], + stale: 0, + timestamp: Date.now(), + expire: Date.now() + 1000, + revalidate: 3600, + value: "test-value", + }, + lastModified: Date.now(), + }), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), +}; +globalThis.cache = cache; + +globalThis.__openNextAls = { + getStore: () => ({ + pendingPromiseRunner: { + withResolvers: vi.fn().mockReturnValue({ + resolve: vi.fn(), + }), + }, + writtenTags: new Set(), + }), +}; + describe("Composable cache handler", () => { vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); - const incrementalCache = { - name: "mock", - get: vi.fn().mockResolvedValue({ - value: { - type: "route", - body: "{}", - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - value: "test-value", - }, - lastModified: Date.now(), - }), - set: vi.fn(), - delete: vi.fn(), - }; - globalThis.incrementalCache = incrementalCache; - const cacheTransport = { - name: "transport", - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - revalidateTags: vi.fn(), - }; - - const tagCache = { - name: "mock", - mode: "original" as string | undefined, - hasBeenRevalidated: vi.fn(), - getByTag: vi.fn().mockResolvedValue(["path1", "path2"]), - getByPath: vi.fn().mockResolvedValue(["tag1"]), - getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), - getLastRevalidated: vi.fn().mockResolvedValue(0), - writeTags: vi.fn(), - }; - globalThis.tagCache = tagCache; - - const invalidateCdnHandler = { - name: "mock", - invalidatePaths: vi.fn(), - }; - globalThis.cdnInvalidationHandler = invalidateCdnHandler; - const writtenTags = new Set(); - - globalThis.__openNextAls = { - getStore: () => ({ - pendingPromiseRunner: { - withResolvers: vi.fn().mockReturnValue({ - resolve: vi.fn(), - }), - }, - writtenTags, - }), - }; - beforeEach(() => { vi.clearAllMocks(); @@ -71,69 +46,20 @@ describe("Composable cache handler", () => { disableTagCache: false, }, }; - globalThis.cache = undefined; - }); - - describe("dedicated cache transport", () => { - beforeEach(() => { - globalThis.cache = cacheTransport; - }); - - it("uses the transport for reads", async () => { - cacheTransport.get.mockResolvedValueOnce({ - value: { - value: "transport-value", - tags: [], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }, - }); - - const result = await ComposableCache.get("key"); - - expect(cacheTransport.get).toHaveBeenCalledWith("key", "composable"); - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(await fromReadableStream(result!.value)).toBe("transport-value"); - }); - - it("uses the transport for writes and tag expiry", async () => { - await ComposableCache.set( - "key", - Promise.resolve({ - value: toReadableStream("value"), - tags: [], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }) - ); - await ComposableCache.expireTags("tag"); - - expect(cacheTransport.set).toHaveBeenCalledWith( - "key", - expect.objectContaining({ value: "value" }), - "composable" - ); - expect(cacheTransport.revalidateTags).toHaveBeenCalledWith(["tag"]); - expect(incrementalCache.set).not.toHaveBeenCalled(); - }); }); describe("get", () => { - it("should return cached entry when available and not revalidated", async () => { + it("should return cached entry when available", async () => { const result = await ComposableCache.get("test-key"); - expect(incrementalCache.get).toHaveBeenCalledWith("test-key", "composable"); + expect(cache.get).toHaveBeenCalledWith("test-key", "composable"); expect(result).toBeDefined(); expect(result?.tags).toEqual(["tag1", "tag2"]); expect(result?.value).toBeInstanceOf(ReadableStream); }); it("should return undefined when cache entry does not exist", async () => { - incrementalCache.get.mockResolvedValueOnce(null); + cache.get.mockResolvedValueOnce(null); const result = await ComposableCache.get("non-existent-key"); @@ -141,7 +67,7 @@ describe("Composable cache handler", () => { }); it("should return undefined when cache entry has no value", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: null, lastModified: Date.now(), }); @@ -151,74 +77,8 @@ describe("Composable cache handler", () => { expect(result).toBeUndefined(); }); - it("should check tag revalidation in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.hasBeenRevalidated).toHaveBeenCalledWith(["tag1", "tag2"], expect.any(Number)); - expect(result).toBeDefined(); - }); - - it("should return undefined when tags have been revalidated in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - - const result = await ComposableCache.get("test-key"); - - expect(result).toBeUndefined(); - }); - - it("should skip tag check when tags array is empty in nextMode", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - tags: [], - value: "test-value", - }, - lastModified: Date.now(), - }); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - - it("should check last modified in original mode", async () => { - tagCache.mode = "original"; - tagCache.getLastModified.mockResolvedValueOnce(Date.now()); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.getLastModified).toHaveBeenCalledWith("test-key", expect.any(Number)); - expect(result).toBeDefined(); - }); - - it("should return undefined when entry has been revalidated in original mode", async () => { - tagCache.mode = "original"; - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await ComposableCache.get("test-key"); - - expect(result).toBeUndefined(); - }); - - it("should handle undefined tag cache mode", async () => { - tagCache.mode = undefined; - tagCache.getLastModified.mockResolvedValueOnce(Date.now()); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.getLastModified).toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - it("should return undefined on cache read error", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Cache error")); + cache.get.mockRejectedValueOnce(new Error("Cache error")); const result = await ComposableCache.get("test-key"); @@ -250,12 +110,7 @@ describe("Composable cache handler", () => { }); describe("set", () => { - beforeEach(() => { - writtenTags.clear(); - }); - - it("should set cache entry and handle tags in original mode", async () => { - tagCache.mode = "original"; + it("should set cache entry", async () => { const entry = { value: toReadableStream("test-value"), tags: ["tag1", "tag2"], @@ -267,7 +122,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("test-key", Promise.resolve(entry)); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "test-key", expect.objectContaining({ tags: ["tag1", "tag2"], @@ -275,64 +130,6 @@ describe("Composable cache handler", () => { }), "composable" ); - expect(tagCache.getByPath).toHaveBeenCalledWith("test-key"); - }); - - it("should write new tags not already stored", async () => { - tagCache.mode = "original"; - tagCache.getByPath.mockResolvedValueOnce(["tag1"]); - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2", "tag3"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { tag: "tag2", path: "test-key" }, - { tag: "tag3", path: "test-key" }, - ]); - }); - - it("should not write tags if all are already stored", async () => { - tagCache.mode = "original"; - tagCache.getByPath.mockResolvedValueOnce(["tag1", "tag2"]); - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.writeTags).not.toHaveBeenCalled(); - }); - - it("should skip tag handling in nextMode", async () => { - tagCache.mode = "nextMode"; - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.getByPath).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); }); it("should convert ReadableStream to string", async () => { @@ -347,7 +144,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("test-key", Promise.resolve(entry)); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "test-key", expect.objectContaining({ value: "test-content", @@ -362,131 +159,43 @@ describe("Composable cache handler", () => { await ComposableCache.refreshTags(); // Should not call any methods - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(incrementalCache.set).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); - describe("getExpiration (Next 15)", () => { - it("should return last revalidated time in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.getLastRevalidated.mockResolvedValueOnce(123456); - - const result = await ComposableCache.getExpiration("tag1", "tag2"); - - expect(tagCache.getLastRevalidated).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(result).toBe(123456); - }); - - it("should return 0 in original mode", async () => { - tagCache.mode = "original"; - + describe("getExpiration", () => { + it("should return 0 regardless of arguments", async () => { const result = await ComposableCache.getExpiration("tag1", "tag2"); expect(result).toBe(0); }); - it("should return 0 when mode is undefined", async () => { - tagCache.mode = undefined; - - const result = await ComposableCache.getExpiration("tag1", "tag2"); - - expect(result).toBe(0); - }); - }); - - describe("getExpiration (Next 16)", () => { - it("should return last revalidated time in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.getLastRevalidated.mockResolvedValueOnce(123456); - - const result = await ComposableCache.getExpiration(["tag1", "tag2"]); - - expect(tagCache.getLastRevalidated).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(result).toBe(123456); - }); - - it("should return 0 in original mode", async () => { - tagCache.mode = "original"; - + it("should return 0 for array argument (Next 16 signature)", async () => { const result = await ComposableCache.getExpiration(["tag1", "tag2"]); expect(result).toBe(0); }); - it("should return 0 when mode is undefined", async () => { - tagCache.mode = undefined; - - const result = await ComposableCache.getExpiration(["tag1", "tag2"]); + it("should return 0 for empty args", async () => { + const result = await ComposableCache.getExpiration(); expect(result).toBe(0); }); }); describe("expireTags", () => { - beforeEach(() => { - writtenTags.clear(); - }); - it("should write tags directly in nextMode", async () => { - tagCache.mode = "nextMode"; - - await ComposableCache.expireTags("tag1", "tag2"); - - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); - }); - - it("should find paths and write tag mappings in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockImplementation(async (tag) => { - if (tag === "tag1") return ["path1", "path2"]; - if (tag === "tag2") return ["path2", "path3"]; - return []; - }); - + it("should call cache.revalidateTags with flat tags array", async () => { await ComposableCache.expireTags("tag1", "tag2"); - expect(tagCache.getByTag).toHaveBeenCalledWith("tag1"); - expect(tagCache.getByTag).toHaveBeenCalledWith("tag2"); - expect(tagCache.writeTags).toHaveBeenCalledWith( - expect.arrayContaining([ - { path: "path1", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag2", revalidatedAt: expect.any(Number) }, - { path: "path3", tag: "tag2", revalidatedAt: expect.any(Number) }, - ]) - ); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); }); - it("should deduplicate paths in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockImplementation(async (tag) => { - if (tag === "tag1") return ["path1", "path2"]; - if (tag === "tag2") return ["path1", "path2"]; - return []; - }); - - await ComposableCache.expireTags("tag1", "tag2"); - - const writtenTags = tagCache.writeTags.mock.calls[0][0]; - expect(writtenTags).toHaveLength(4); // 2 paths × 2 tags = 4 unique combinations - expect(writtenTags).toEqual( - expect.arrayContaining([ - { path: "path1", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path1", tag: "tag2", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag2", revalidatedAt: expect.any(Number) }, - ]) - ); - }); - - it("should handle empty paths in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockResolvedValue([]); - - await ComposableCache.expireTags("tag1"); + it("should not call revalidateTags when no tags provided", async () => { + await ComposableCache.expireTags(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); @@ -495,9 +204,9 @@ describe("Composable cache handler", () => { await ComposableCache.receiveExpiredTags("tag1", "tag2"); // Should not call any methods - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(incrementalCache.set).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); @@ -516,7 +225,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("integration-key", Promise.resolve(entry)); // Verify it was stored - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "integration-key", expect.objectContaining({ value: "integration-test", @@ -526,7 +235,7 @@ describe("Composable cache handler", () => { ); // Mock the get response - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { ...entry, value: "integration-test", @@ -574,8 +283,8 @@ describe("Composable cache handler", () => { const results = await Promise.all(promises); - expect(incrementalCache.set).toHaveBeenCalledTimes(2); - expect(incrementalCache.get).not.toHaveBeenCalled(); + expect(cache.set).toHaveBeenCalledTimes(2); + expect(cache.get).not.toHaveBeenCalled(); expect(results[2]).toBeDefined(); expect(results[3]).toBeDefined(); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 7d7086ef..0dcfde57 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -2,7 +2,7 @@ import { cacheInterceptor } from "@opennextjs/core/core/routing/cacheInterceptor import { convertFromQueryString } from "@opennextjs/core/core/routing/util.js"; import type { MiddlewareEvent } from "@opennextjs/core/types/open-next.js"; import type { Queue } from "@opennextjs/core/types/overrides.js"; -import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream.js"; +import { fromReadableStream } from "@opennextjs/core/utils/stream.js"; import { vi } from "vitest"; vi.mock("@/config/index.js", () => ({ @@ -37,7 +37,7 @@ function createEvent(event: PartialEvent): MiddlewareEvent { method: event.method ?? "GET", rawPath, url: event.url ?? "/", - body: event.body !== undefined ? toReadableStream(event.body) : undefined, + body: Buffer.from(event.body ?? ""), headers: event.headers ?? {}, query: convertFromQueryString(qs ?? ""), cookies: event.cookies ?? {}, @@ -46,29 +46,14 @@ function createEvent(event: PartialEvent): MiddlewareEvent { }; } -const incrementalCache = { +const cache = { name: "mock", get: vi.fn(), set: vi.fn(), delete: vi.fn(), -}; - -const cacheTransport = { - name: "transport", - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), revalidateTags: vi.fn(), }; -const tagCache = { - name: "mock", - getByTag: vi.fn(), - getByPath: vi.fn(), - getLastModified: vi.fn(), - writeTags: vi.fn(), -}; - const queue = { name: "mock", send: vi.fn(), @@ -76,12 +61,10 @@ const queue = { declare global { var queue: Queue; - var incrementalCache: any; - var tagCache: any; + var cache: any; } -globalThis.incrementalCache = incrementalCache; -globalThis.tagCache = tagCache; +globalThis.cache = cache; globalThis.queue = queue; beforeEach(() => { @@ -93,7 +76,6 @@ beforeEach(() => { disableIncrementalCache: false, }, }; - globalThis.cache = undefined; }); describe("cacheInterceptor", () => { @@ -119,12 +101,12 @@ describe("cacheInterceptor", () => { expect(result).toEqual(event); }); - it("should take no action when incremental cache throws", async () => { + it("should take no action when cache throws", async () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockRejectedValueOnce(new Error("mock error")); + cache.get.mockRejectedValueOnce(new Error("mock error")); const result = await cacheInterceptor(event); expect(result).toEqual(event); @@ -134,7 +116,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -160,80 +142,11 @@ describe("cacheInterceptor", () => { ); }); - it("should retrieve content through the dedicated cache transport", async () => { - globalThis.cache = cacheTransport; - cacheTransport.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "From transport", - }, - }); - - const result = await cacheInterceptor(createEvent({ url: "/albums" })); - - expect(cacheTransport.get).toHaveBeenCalledWith("/albums"); - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(await fromReadableStream(result.body)).toBe("From transport"); - }); - - it("should take no action when tagCache lasModified is -1 for app type", async () => { - const event = createEvent({ - url: "/albums", - }); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "Hello, world!", - }, - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cacheInterceptor(event); - - expect(result).toEqual(event); - }); - - it("should bypass the tag cache when shouldBypassTagCache is true", async () => { - const event = createEvent({ - url: "/albums", - }); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "Hello, world!", - }, - shouldBypassTagCache: true, - }); - - await cacheInterceptor(event); - - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - }); - - it("should take no action when tagCache lasModified is -1 for route type", async () => { - const event = createEvent({ - url: "/albums", - }); - - const body = "route"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: body, - revalidate: false, - }, - lastModified: new Date("2024-01-01T23:58:00Z").getTime(), - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - const result = await cacheInterceptor(event); - expect(result).toEqual(event); - }); - it("should retrieve page router content from stale cache", async () => { const event = createEvent({ url: "/revalidate", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "Hello, world!", @@ -265,7 +178,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/revalidate", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "Hello, world!", @@ -297,7 +210,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "redirect", meta: { @@ -326,7 +239,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "?", html: "Hello, world!", @@ -343,7 +256,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = JSON.stringify({ message: "Hello from API" }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -383,7 +296,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "randomBinaryData"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -423,7 +336,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "API response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -465,7 +378,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "Simple response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -498,7 +411,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 403, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -514,7 +427,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 203, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -532,7 +445,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -550,7 +463,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index 8da313b6..d03d3eaa 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -178,6 +178,12 @@ describe("fetch cache", () => { expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=composable", expect.any(Object)); }); + it("should forward additional tags", async () => { + await fetchCache.set("key", {}, "fetch", ["tag1", "tag2"]); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=fetch&tags=tag1%2Ctag2", expect.any(Object)); + }); + it("should reject an unsuccessful response", async () => { mockFetch({ headers: {}, body: "", status: 500 }); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index 45eb1f8a..db8d559b 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -187,6 +187,15 @@ describe("local cache", () => { expect(event.query).toEqual({ type: "composable" }); }); + it("should forward additional tags", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.set("key", {}, "fetch", ["tag1", "tag2"]); + + const event = mockHandler.mock.calls[0][0]; + expect(event.query).toEqual({ type: "fetch", tags: "tag1,tag2" }); + }); + it("should reject an unsuccessful response", async () => { mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 }));