diff --git a/.changeset/cache-handler-function.md b/.changeset/cache-handler-function.md new file mode 100644 index 00000000..1d0659d6 --- /dev/null +++ b/.changeset/cache-handler-function.md @@ -0,0 +1,31 @@ +--- +"@opennextjs/core": minor +--- + +Add a dedicated cache handler function + +The incremental cache, the tag cache and CDN invalidation can now run in their own function, bundled +to `.open-next/cache-function` and reachable over the new `cache` override. + +The overrides for that function are configured through a new top level `cacheHandler` option: + +```ts +export default { + default: { + override: { + cache: "local", + }, + }, + cacheHandler: { + incrementalCache: "s3", + tagCache: "dynamodb", + }, +} satisfies OpenNextConfig; +``` + +Three `cache` implementations ship with core: `fetch` (HTTP), `local` (in-process, imports the cache +bundle) and `dummy`. Adapters that do not want the bundle can opt out with `skipCache` in +`buildAdapter`. + +This is additive: the existing `default.override.incrementalCache` and `default.override.tagCache` +keep working unchanged. diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts new file mode 100644 index 00000000..d04abe1b --- /dev/null +++ b/packages/core/src/adapters/cache-adapter.ts @@ -0,0 +1,8 @@ +import { createGenericHandler } from "../core/createGenericHandler.js"; + +import { handler as cacheHandler } from "./cache-handler.js"; + +export const handler = await createGenericHandler({ + handler: cacheHandler, + type: "cache", +}); diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts new file mode 100644 index 00000000..79b2013c --- /dev/null +++ b/packages/core/src/adapters/cache-handler.ts @@ -0,0 +1,493 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ReadableStream } from "node:stream/web"; + +import type { StoredComposableCacheEntry } from "@/types/cache"; +import type { InternalEvent, InternalResult } from "@/types/open-next"; +import type { + CacheEntryType, + CachedFile, + CachedFetchValue, + CacheValue, + OpenNextHandlerOptions, + WithLastModified, +} from "@/types/overrides"; + +import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; +import { writeTags } from "../utils/cache.js"; +import { runWithOpenNextRequestContext } from "../utils/promise.js"; +import { fromReadableStream, toReadableStream } from "../utils/stream.js"; + +import { debug, error } from "./logger.js"; + +globalThis.__openNextAls ??= new AsyncLocalStorage(); + +const SOFT_TAG_PREFIX = "_N_T_/"; + +// Whether caches have been initialized +let initialized = false; + +async function initializeCaches() { + if (initialized) return; + const config = globalThis.openNextConfig; + + globalThis.incrementalCache = await resolveIncrementalCache( + config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache + ); + + globalThis.tagCache = await resolveTagCache( + config.cacheHandler?.tagCache ?? config.default?.override?.tagCache + ); + + globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( + config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation + ); + + initialized = true; +} + +///////////// +// Handler // +///////////// + +/** + * Handles an internal cache function request. + * + * @param event Normalized cache request. + * @param options Handler execution options. + * @return The normalized cache response. + */ +export async function handler( + event: InternalEvent, + options?: OpenNextHandlerOptions +): Promise { + debug("cache handler event", event); + + try { + await initializeCaches(); + } catch (e) { + error("Failed to initialize caches", e); + return buildErrorResponse("Internal server error", 500); + } + + const { method, rawPath, query, body } = event; + + try { + // POST /cache/revalidate-tags + if (method === "POST" && rawPath === "/cache/revalidate-tags") { + return await handleRevalidateTags(body); + } + + // All other operations must be on /cache/* + if (!rawPath.startsWith("/cache/")) { + return buildErrorResponse("Not Found", 404); + } + + const key = decodeURIComponent(rawPath.slice("/cache/".length)); + + if (!key) { + return buildErrorResponse("Missing cache key", 400); + } + + const rawType = typeof query?.type === "string" ? query.type : undefined; + const cacheType: CacheEntryType = rawType === "fetch" || rawType === "composable" ? rawType : "cache"; + + switch (method) { + case "GET": + return await handleGet(key, cacheType); + case "PUT": + return await handleSet(key, cacheType, body); + case "DELETE": + return await handleDelete(key); + default: + return buildErrorResponse("Method Not Allowed", 405); + } + } catch (e) { + error("Failed to handle cache request", e); + return buildErrorResponse("Internal server error", 500); + } +} + +////////////////////// +// Route handlers // +////////////////////// + +async function handleGet(key: string, cacheType: CacheEntryType): Promise { + debug("get", { key, cacheType }); + + try { + const result = await globalThis.incrementalCache.get(key, cacheType); + + if (!result?.value) { + return { + type: "core", + statusCode: 404, + body: toReadableStream(""), + isBase64Encoded: false, + headers: { + "x-opennext-cache-found": "false", + "Cache-Control": "no-store", + }, + }; + } + + return buildCacheGetResponse(result); + } catch (e) { + error("Failed to get cache entry", e); + return buildErrorResponse("Failed to get cache entry", 500); + } +} + +/** + * 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 body Stream containing the serialized cache value. + * @return The cache operation response. + * @throws When reading the request stream fails. + */ +async function handleSet( + key: string, + cacheType: CacheEntryType, + body?: ReadableStream +): Promise { + debug("set", { key, cacheType }); + const bodyText = body ? await fromReadableStream(body) : ""; + + let payload: { + value?: Record; + } = {}; + + if (bodyText.length > 0) { + try { + payload = JSON.parse(bodyText); + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + } + + if (!payload.value) { + return buildErrorResponse("Missing 'value' in request body", 400); + } + + try { + await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to set cache entry", e); + return buildErrorResponse("Failed to set cache entry", 500); + } +} + +async function handleDelete(key: string): Promise { + debug("delete", { key }); + + try { + await globalThis.incrementalCache.delete(key); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to delete cache entry", e); + return buildErrorResponse("Failed to delete cache entry", 500); + } +} + +/** + * Revalidates cache tags from a streamed JSON request body. + * + * @param body Stream containing the tags to revalidate. + * @return The cache operation response. + * @throws When reading the request stream fails. + */ +async function handleRevalidateTags(body?: ReadableStream): Promise { + debug("revalidateTags"); + const bodyText = body ? await fromReadableStream(body) : ""; + + if (bodyText.length === 0) { + return buildErrorResponse("Missing request body", 400); + } + + let tags: string[]; + try { + const parsed = JSON.parse(bodyText) as { tags?: unknown }; + tags = + Array.isArray(parsed.tags) && + parsed.tags.every((tag: unknown): tag is string => typeof tag === "string") + ? parsed.tags + : []; + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + + if (tags.length === 0) { + return buildErrorResponse("Missing 'tags' array in request body", 400); + } + + try { + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + if (globalThis.tagCache.mode === "nextMode") { + const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; + + await writeTags(tags); + if (paths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + paths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + return; + } + + for (const tag of tags) { + debug("revalidateTag", tag); + const paths = await globalThis.tagCache.getByTag(tag); + debug("Items", paths); + const toInsert = paths.map((path) => ({ + path, + tag, + })); + + if (tag.startsWith(SOFT_TAG_PREFIX)) { + for (const path of paths) { + const _tags = await globalThis.tagCache.getByPath(path); + const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); + for (const hardTag of hardTags) { + const _paths = await globalThis.tagCache.getByTag(hardTag); + debug({ hardTag, _paths }); + toInsert.push( + ..._paths.map((path) => ({ + path, + tag: hardTag, + })) + ); + } + } + } + + await writeTags(toInsert); + + const uniquePaths = Array.from( + new Set(toInsert.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, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + } + }); + + return buildJsonResponse({ revalidated: tags }, 200); + } catch (e) { + error("Failed to revalidate tags", e); + return buildErrorResponse("Failed to revalidate tags", 500); + } +} + +///////////////////////////// +// Cache GET response builder // +///////////////////////////// + +function buildCacheGetResponse(result: WithLastModified>): InternalResult { + const value = result.value!; + + const headers: Record = { + "x-opennext-cache-found": "true", + "Cache-Control": "no-store", + }; + + if (result.lastModified !== undefined) { + headers["x-opennext-cache-last-modified"] = String(result.lastModified); + } + if (result.shouldBypassTagCache) { + headers["x-opennext-cache-should-bypass"] = "true"; + } + + if ("kind" in value && value.kind === "FETCH") { + return buildFetchResponse(value as CachedFetchValue, headers); + } + + if ("type" in value) { + return buildCachedFileResponse(value as CachedFile, headers); + } + + return buildComposableResponse(value as StoredComposableCacheEntry, headers); +} + +function buildFetchResponse( + value: CacheValue<"fetch">, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "fetch"; + headers["x-opennext-cache-fetch-kind"] = "FETCH"; + headers["x-opennext-cache-fetch-data-url"] = value.data.url; + + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + + if (value.data.status !== undefined) { + headers["x-opennext-cache-fetch-data-status"] = String(value.data.status); + } + if (value.data.tags) { + headers["x-opennext-cache-fetch-data-tags"] = JSON.stringify(value.data.tags); + } + if (value.tags) { + headers["x-opennext-cache-fetch-tags"] = JSON.stringify(value.tags); + } + + for (const [key, val] of Object.entries(value.data.headers)) { + headers[`x-opennext-cache-header-${key}`] = val; + } + + const body = value.data.body; + return { + type: "core", + statusCode: 200, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +function buildCachedFileResponse( + value: CacheValue<"cache">, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "cache"; + headers["x-opennext-cache-sub-type"] = value.type; + + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + + if (value.meta?.status !== undefined) { + headers["x-opennext-cache-meta-status"] = String(value.meta.status); + } + if (value.meta?.postponed !== undefined) { + headers["x-opennext-cache-meta-postponed"] = value.meta.postponed; + } + if (value.meta?.headers) { + for (const [key, val] of Object.entries(value.meta.headers)) { + if (val !== undefined) { + headers[`x-opennext-cache-header-${key}`] = val; + } + } + } + + switch (value.type) { + case "route": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; + } + case "page": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify({ json: value.json, html: value.html })), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "app": { + return { + type: "core", + statusCode: 200, + body: toReadableStream( + JSON.stringify({ + html: value.html, + rsc: value.rsc, + segmentData: value.segmentData, + }) + ), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "redirect": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify(value.props ?? {})), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + } +} + +function buildComposableResponse( + value: StoredComposableCacheEntry, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "composable"; + headers["x-opennext-cache-composable-stale"] = String(value.stale); + headers["x-opennext-cache-composable-expire"] = String(value.expire); + headers["x-opennext-cache-composable-timestamp"] = String(value.timestamp); + headers["x-opennext-cache-composable-revalidate"] = String(value.revalidate); + headers["x-opennext-cache-composable-tags"] = JSON.stringify(value.tags); + + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.value), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +////////////////////////// +// Response builders // +////////////////////////// + +function buildJsonResponse(data: unknown, statusCode: number): InternalResult { + const body = JSON.stringify(data); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} + +function buildErrorResponse(message: string, statusCode: number): InternalResult { + debug(message, statusCode); + const body = JSON.stringify({ error: message }); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 9cd66847..cb8a65f6 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -7,6 +7,15 @@ 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"; @@ -37,7 +46,7 @@ export default class Cache { async getFetchCache(key: string, softTags?: string[], tags?: string[]) { debug("get fetch cache", { key, softTags, tags }); try { - const cachedEntry = await globalThis.incrementalCache.get(key, "fetch"); + const cachedEntry = await getCacheStorage().get(key, "fetch"); if (cachedEntry?.value === undefined) return null; @@ -80,7 +89,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); + const cachedEntry = await getCacheStorage().get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -174,14 +183,14 @@ export default class Cache { const detachedPromise = globalThis.__openNextAls.getStore()?.pendingPromiseRunner.withResolvers(); try { if (data === null || data === undefined) { - await globalThis.incrementalCache.delete(key); + await getCacheStorage().delete(key); } else { const revalidate = this.extractRevalidateForSet(ctx); switch (data.kind) { case "ROUTE": case "APP_ROUTE": { const { body, status, headers } = data; - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "route", @@ -201,7 +210,7 @@ export default class Cache { const { html, pageData, status, headers } = data; const isAppPath = typeof pageData === "string"; if (isAppPath) { - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "app", @@ -216,7 +225,7 @@ export default class Cache { "cache" ); } else { - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "page", @@ -237,7 +246,7 @@ export default class Cache { segmentToWrite[segmentPath] = segmentContent.toString("utf8"); } } - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "app", @@ -256,10 +265,10 @@ export default class Cache { break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, "fetch"); + await getCacheStorage().set(key, data, "fetch"); break; case "REDIRECT": - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "redirect", @@ -296,6 +305,11 @@ 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)) ?? []; diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index a6fb19c3..40a81f21 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -7,6 +7,15 @@ 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 { @@ -21,7 +30,7 @@ export default { })); } } - const result = await globalThis.incrementalCache.get(cacheKey, "composable"); + const result = await getCacheStorage().get(cacheKey, "composable"); if (!result?.value?.value) { return undefined; } @@ -61,7 +70,7 @@ export default { const entry = await promiseEntry.finally(() => { pendingWritePromiseMap.delete(cacheKey); }); - await globalThis.incrementalCache.set( + await getCacheStorage().set( cacheKey, { ...entry, @@ -102,6 +111,9 @@ 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); } diff --git a/packages/core/src/adapters/middleware.ts b/packages/core/src/adapters/middleware.ts index 295ec809..43038f8d 100644 --- a/packages/core/src/adapters/middleware.ts +++ b/packages/core/src/adapters/middleware.ts @@ -11,6 +11,7 @@ import { debug, error } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; import { resolveAssetResolver, + resolveCache, resolveIncrementalCache, resolveOriginResolver, resolveProxyRequest, @@ -46,6 +47,8 @@ const defaultHandler = async ( globalThis.incrementalCache = await resolveIncrementalCache(middlewareConfig?.override?.incrementalCache); + globalThis.cache = await resolveCache(middlewareConfig?.override?.cache); + const requestId = Math.random().toString(36); // We run everything in the async local storage context so that it is available in the external middleware diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index edfcd2db..a9e6b95c 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -68,6 +68,10 @@ vi.mock("./createImageOptimizationBundle.js", () => ({ createImageOptimizationBundle: vi.fn(), })); +vi.mock("./createCacheBundle.js", () => ({ + createCacheBundle: vi.fn(), +})); + vi.mock("./createWarmerBundle.js", () => ({ createWarmerBundle: vi.fn(), })); @@ -98,6 +102,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createStaticAssets, createCacheAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -604,7 +609,9 @@ describe("buildAdapter", () => { await adapter.modifyConfig(nextConfig, { phase: "production" }); const ctx = createMockContext(); await adapter.onBuildComplete(ctx); - expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object), undefined); + expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object), undefined, { + skipCache: undefined, + }); const fs = await import("node:fs"); expect(fs.default.writeFileSync).toHaveBeenCalledWith( expect.stringMatching(/\/\.open-next\/open-next\.output\.json$/), diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 19829f91..00a61a5a 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -16,6 +16,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createCacheAssets, createStaticAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -60,6 +61,7 @@ export type OpenNextAdapterOptions = { skipRevalidation?: boolean; skipImageOptimization?: boolean; skipWarmer?: boolean; + skipCache?: boolean; skipGenerateOutput?: boolean; middlewareOptions?: { forceOnlyBuildOnce?: boolean }; middlewareBundle?: { @@ -262,17 +264,25 @@ export function buildAdapter( logger.info("Image optimization bundle created"); } - // Step 11: Warmer bundle + // Step 11: Cache bundle + if (!adapterOptions.skipCache && config.dangerous?.disableIncrementalCache !== true) { + await createCacheBundle(buildOpts, bundleDefaults?.cache); + logger.info("Cache bundle created"); + } + + // Step 12: Warmer bundle if (!adapterOptions.skipWarmer) { await createWarmerBundle(buildOpts, bundleDefaults?.warmer); logger.info("Warmer bundle created"); } - // Step 12: Generate output + // Step 13: Generate output if (!adapterOptions.skipGenerateOutput) { const output = adapterOptions.generateOutput ? await adapterOptions.generateOutput(buildOpts) - : await buildOpenNextOutput(buildOpts, bundleDefaults); + : await buildOpenNextOutput(buildOpts, bundleDefaults, { + skipCache: adapterOptions.skipCache, + }); fs.writeFileSync( path.join(buildOpts.appBuildOutputPath, ".open-next", "open-next.output.json"), JSON.stringify(output) diff --git a/packages/core/src/build/createCacheBundle.spec.ts b/packages/core/src/build/createCacheBundle.spec.ts new file mode 100644 index 00000000..8b9d07d4 --- /dev/null +++ b/packages/core/src/build/createCacheBundle.spec.ts @@ -0,0 +1,116 @@ +import path from "node:path"; + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { openNextResolvePlugin } from "../plugins/resolve.js"; + +import { createCacheBundle } from "./createCacheBundle.js"; +import * as buildHelper from "./helper.js"; + +vi.mock("node:fs", () => ({ + default: { mkdirSync: vi.fn() }, +})); + +vi.mock("../plugins/resolve.js", () => ({ + openNextResolvePlugin: vi.fn(() => ({ + name: "opennext-resolve", + setup: vi.fn(), + })), +})); + +vi.mock("./helper.js", () => ({ + copyOpenNextConfig: vi.fn(), + esbuildAsync: vi.fn(), +})); + +describe("createCacheBundle", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("builds wrapped and raw cache handler entrypoints", async () => { + const options = { + buildDir: "/app/.open-next/.build", + config: { default: {} }, + openNextDistDir: "/core/dist", + outputDir: "/app/.open-next", + }; + + await createCacheBundle(options as never); + + expect(buildHelper.esbuildAsync).toHaveBeenCalledWith( + expect.objectContaining({ + entryPoints: { + index: path.join(options.openNextDistDir, "adapters", "cache-adapter.js"), + handler: path.join(options.openNextDistDir, "adapters", "cache-handler.js"), + }, + outdir: path.join(options.outputDir, "cache-function"), + outExtension: { ".js": ".mjs" }, + }), + options + ); + }); + + test("falls back to legacy default function cache providers", async () => { + const options = { + buildDir: "/app/.open-next/.build", + config: { + default: { + override: { + incrementalCache: "legacy-incremental", + tagCache: "legacy-tags", + cdnInvalidation: "legacy-cdn", + }, + }, + }, + openNextDistDir: "/core/dist", + outputDir: "/app/.open-next", + }; + + await createCacheBundle(options as never); + + expect(openNextResolvePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + overrides: expect.objectContaining({ + incrementalCache: "legacy-incremental", + tagCache: "legacy-tags", + cdnInvalidation: "legacy-cdn", + }), + }) + ); + }); + + test("prefers cache handler providers over legacy defaults", async () => { + const options = { + buildDir: "/app/.open-next/.build", + config: { + default: { + override: { + incrementalCache: "legacy-incremental", + tagCache: "legacy-tags", + cdnInvalidation: "legacy-cdn", + }, + }, + cacheHandler: { + incrementalCache: "handler-incremental", + tagCache: "handler-tags", + cdnInvalidation: "handler-cdn", + }, + }, + openNextDistDir: "/core/dist", + outputDir: "/app/.open-next", + }; + + await createCacheBundle(options as never); + + expect(openNextResolvePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + overrides: expect.objectContaining({ + incrementalCache: "handler-incremental", + tagCache: "handler-tags", + cdnInvalidation: "handler-cdn", + }), + }) + ); + }); +}); diff --git a/packages/core/src/build/createCacheBundle.ts b/packages/core/src/build/createCacheBundle.ts new file mode 100644 index 00000000..375cc4b7 --- /dev/null +++ b/packages/core/src/build/createCacheBundle.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; + +import logger from "../logger.js"; +import type { DefaultOverrides } from "../plugins/resolve.js"; +import { openNextResolvePlugin } from "../plugins/resolve.js"; + +import * as buildHelper from "./helper.js"; + +/** + * Builds the deployable and in-process cache handler entrypoints. + * + * @param options Normalized build options. + * @param defaultOverrides Adapter defaults for the cache function. + * @return A promise that resolves when both entrypoints are bundled. + */ +export async function createCacheBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { + logger.info("Bundling cache function..."); + + const { config, outputDir } = options; + + // Create output folder + const outputPath = path.join(outputDir, "cache-function"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Copy open-next.config.mjs into the bundle + buildHelper.copyOpenNextConfig(options.buildDir, outputPath); + + // Build the wrapped deployment entrypoint and raw in-process handler. + await buildHelper.esbuildAsync( + { + external: ["next"], + entryPoints: { + index: path.join(options.openNextDistDir, "adapters", "cache-adapter.js"), + handler: path.join(options.openNextDistDir, "adapters", "cache-handler.js"), + }, + outdir: outputPath, + outExtension: { ".js": ".mjs" }, + plugins: [ + openNextResolvePlugin({ + fnName: "cache", + 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, + cdnInvalidation: config.cacheHandler?.cdnInvalidation ?? config.default.override?.cdnInvalidation, + }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "node", + wrapper: defaultOverrides?.wrapper, + incrementalCache: defaultOverrides?.incrementalCache, + tagCache: defaultOverrides?.tagCache, + cdnInvalidation: defaultOverrides?.cdnInvalidation, + }, + }), + ], + }, + options + ); +} diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index 9cd63616..2e090ccf 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -79,6 +79,7 @@ export async function buildEdgeBundle({ converter: override("converter"), tagCache: override("tagCache"), incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -89,6 +90,7 @@ export async function buildEdgeBundle({ 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: defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index 25e61297..f5d0b45c 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -88,6 +88,7 @@ export interface OpenNextOutput { initializationFunction?: BaseFunction; warmer?: BaseFunction; revalidationFunction?: BaseFunction; + cacheFunction?: BaseFunction; }; } @@ -193,11 +194,13 @@ function prefixPattern(basePath: string) { * * @param options Normalized build options. * @param bundleDefaults Adapter defaults used to build each bundle type. + * @param outputOptions Output-generation options. * @return Deployment metadata matching the effective bundle overrides. */ export async function buildOpenNextOutput( options: BuildOptions, - bundleDefaults?: BundleDefaults + bundleDefaults?: BundleDefaults, + { skipCache = false }: { skipCache?: boolean } = {} ): Promise { const { appBuildOutputPath, config } = options; const edgeFunctions: OpenNextOutput["edgeFunctions"] = {}; @@ -391,6 +394,13 @@ export async function buildOpenNextOutput( handler: indexHandler, bundle: ".open-next/revalidation-function", }, + cacheFunction: + skipCache || config.dangerous?.disableIncrementalCache + ? undefined + : { + handler: indexHandler, + bundle: ".open-next/cache-function", + }, }, }; return output; diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts index 060590db..3cdba4bc 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts @@ -55,6 +55,7 @@ vi.mock("node:fs", () => ({ import fs from "node:fs"; +import { openNextResolvePlugin } from "../../plugins/resolve.js"; import type { NextAdapterOutputs } from "../../types/adapter.js"; import type { BuildOptions } from "../helper.js"; import * as buildHelper from "../helper.js"; @@ -221,4 +222,23 @@ describe("buildExternalNodeMiddleware", () => { const esbuildOptions = esbuildCall[0]; expect(esbuildOptions.banner?.js).toContain("// banner for middleware"); }); + + test("forwards cache overrides to the resolve plugin", async () => { + const options = createMockBuildOpts({ + config: { + default: {}, + dangerous: {}, + middleware: { external: true, override: { cache: "fetch" } }, + } as BuildOptions["config"], + }); + + await buildExternalNodeMiddleware(options, { cache: "platform-cache" }, createMockNextOutputs()); + + expect(openNextResolvePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + overrides: expect.objectContaining({ cache: "fetch" }), + defaultOverrides: expect.objectContaining({ cache: "platform-cache" }), + }) + ); + }); }); diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index a20aa95f..6fe5e094 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -130,6 +130,7 @@ export async function buildExternalNodeMiddleware( converter: override("converter"), tagCache: override("tagCache"), incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -140,6 +141,7 @@ export async function buildExternalNodeMiddleware( 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: defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", diff --git a/packages/core/src/core/createGenericHandler.ts b/packages/core/src/core/createGenericHandler.ts index a8674936..758c9dfd 100644 --- a/packages/core/src/core/createGenericHandler.ts +++ b/packages/core/src/core/createGenericHandler.ts @@ -11,7 +11,22 @@ import { debug } from "../adapters/logger"; import { resolveConverter, resolveWrapper } from "./resolve"; -type HandlerType = "imageOptimization" | "revalidate" | "warmer" | "middleware" | "initializationFunction"; +type HandlerType = + | "imageOptimization" + | "revalidate" + | "warmer" + | "middleware" + | "initializationFunction" + | "cache"; + +const handlerTypeToConfigKey: Record = { + imageOptimization: "imageOptimization", + revalidate: "revalidate", + warmer: "warmer", + middleware: "middleware", + initializationFunction: "initializationFunction", + cache: "cacheHandler", +}; type GenericHandler< Type extends HandlerType, @@ -31,7 +46,8 @@ export async function createGenericHandler< const config: OpenNextConfig = await import("./open-next.config.mjs").then((m) => m.default); globalThis.openNextConfig = config; - const handlerConfig = config[handler.type]; + const configKey = handlerTypeToConfigKey[handler.type]; + const handlerConfig = config[configKey]; const override = handlerConfig && "override" in handlerConfig ? (handlerConfig.override as DefaultOverrideOptions) diff --git a/packages/core/src/core/createMainHandler.ts b/packages/core/src/core/createMainHandler.ts index 480aa21b..eb6129b3 100644 --- a/packages/core/src/core/createMainHandler.ts +++ b/packages/core/src/core/createMainHandler.ts @@ -6,6 +6,7 @@ import { generateUniqueId } from "../adapters/util"; import { openNextHandler } from "./requestHandler"; import { resolveAssetResolver, + resolveCache, resolveCdnInvalidation, resolveConverter, resolveIncrementalCache, @@ -41,6 +42,8 @@ export async function createMainHandler() { ); } + globalThis.cache = await resolveCache(thisFunction.override?.cache); + globalThis.proxyExternalRequest = await resolveProxyRequest(thisFunction.override?.proxyExternalRequest); globalThis.cdnInvalidationHandler = await resolveCdnInvalidation(thisFunction.override?.cdnInvalidation); diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 8bb13557..9027033f 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -7,7 +7,7 @@ import type { OpenNextConfig, OverrideOptions, } from "@/types/open-next"; -import type { Converter, TagCache, Wrapper } from "@/types/overrides"; +import type { Cache, Converter, TagCache, Wrapper } from "@/types/overrides"; // Just a little utility type to remove undefined from a type type RemoveUndefined = T extends undefined ? never : T; @@ -157,3 +157,20 @@ export async function resolveCdnInvalidation(cdnInvalidation: OverrideOptions["c const m_1 = await import("../overrides/cdnInvalidation/dummy.js"); return m_1.default; } + +/** + * Resolves the configured dedicated cache transport. + * + * @param cache Cache transport override. + * @return The configured transport, or undefined when dedicated caching is not enabled. + */ +export async function resolveCache(cache: OverrideOptions["cache"]): Promise { + if (cache === undefined) { + return undefined; + } + if (typeof cache === "function") { + return cache(); + } + const m_1 = await import("../overrides/cache/dummy.js"); + return m_1.default; +} diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index 53f6edb4..fa340e29 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -340,7 +340,7 @@ export async function cacheInterceptor( } else if (localizedPath === "") { pathToUse = "/index"; } - const cachedData = await globalThis.incrementalCache.get(pathToUse); + const cachedData = await (globalThis.cache ?? globalThis.incrementalCache).get(pathToUse); debug("cached data in interceptor", cachedData); if (!cachedData?.value) { diff --git a/packages/core/src/overrides/cache/dummy.ts b/packages/core/src/overrides/cache/dummy.ts new file mode 100644 index 00000000..f48cb92b --- /dev/null +++ b/packages/core/src/overrides/cache/dummy.ts @@ -0,0 +1,20 @@ +import type { Cache } from "@/types/overrides"; +import { IgnorableError } from "@/utils/error"; + +const dummyCache: Cache = { + name: "dummy", + get: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + set: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + delete: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + revalidateTags: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, +}; + +export default dummyCache; diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts new file mode 100644 index 00000000..77c2f954 --- /dev/null +++ b/packages/core/src/overrides/cache/fetch.ts @@ -0,0 +1,62 @@ +import type { Cache } from "@/types/overrides"; +import { parseCacheGetResponse } from "@/utils/cache-get"; + +const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; + +/** + * Rejects unsuccessful cache mutation responses. + * + * @param response Cache handler response. + * @param operation Mutation being performed. + * @throws When the cache handler returns a non-success status. + */ +function ensureResponseOk(response: Pick, operation: string): void { + if (!response.ok) { + throw new Error(`Failed to ${operation}: cache handler returned ${response.status}`); + } +} + +const fetchCache: Cache = { + name: "fetch-cache", + get: async (key, cacheType) => { + const query: Record = {}; + if (cacheType) query.type = cacheType; + 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" }); + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((v, k) => { + headers[k] = v; + }); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return parseCacheGetResponse(headers, bodyText) as any; + }, + set: async (key, value, cacheType) => { + // 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 url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; + const response = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + ensureResponseOk(response, "set cache entry"); + }, + delete: async (key) => { + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; + const response = await fetch(url, { method: "DELETE" }); + ensureResponseOk(response, "delete cache entry"); + }, + revalidateTags: async (tags) => { + const response = await fetch(`${CACHE_URL}/cache/revalidate-tags`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags }), + }); + ensureResponseOk(response, "revalidate cache tags"); + }, +}; + +export default fetchCache; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts new file mode 100644 index 00000000..167e2751 --- /dev/null +++ b/packages/core/src/overrides/cache/local.ts @@ -0,0 +1,118 @@ +import path from "node:path"; + +import type { InternalEvent, InternalResult } from "@/types/open-next"; +import type { Cache } from "@/types/overrides"; +import { parseCacheGetResponse } from "@/utils/cache-get"; +import { getMonorepoRelativePath } from "@/utils/normalize-path"; +import { fromReadableStream, toReadableStream } from "@/utils/stream"; + +let handler: ((event: InternalEvent) => Promise) | null = null; + +/** + * Loads the raw in-process cache handler. + * + * @return The cached internal cache handler. + * @throws When the cache handler bundle cannot be imported. + */ +async function getHandler() { + if (!handler) { + const cacheHandlerPath = path.join(getMonorepoRelativePath(), "cache-function/handler.mjs"); + const m = await import(cacheHandlerPath); + handler = m.handler; + } + return handler; +} + +/** + * Rejects unsuccessful in-process cache mutations. + * + * @param result Internal cache handler response. + * @param operation Mutation being performed. + * @throws When the cache handler returns a non-success status. + */ +function ensureResultOk(result: InternalResult, operation: string): void { + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(`Failed to ${operation}: cache handler returned ${result.statusCode}`); + } +} + +const localCache: Cache = { + name: "local-cache", + get: async (key, cacheType) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + const event: InternalEvent = { + type: "core", + method: "GET", + rawPath: `/cache/${encodedKey}`, + url, + headers: {}, + query: cacheType ? { type: cacheType } : {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; + const result = await h(event); + const bodyText = result.body ? await fromReadableStream(result.body) : ""; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return parseCacheGetResponse(result.headers, bodyText) as any; + }, + set: async (key, value, cacheType) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + // 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 query: Record = {}; + if (cacheType) query.type = cacheType; + const event: InternalEvent = { + type: "core", + method: "PUT", + rawPath: `/cache/${encodedKey}`, + url, + headers: { "Content-Type": "application/json" }, + query, + cookies: {}, + remoteAddress: "127.0.0.1", + body: toReadableStream(JSON.stringify({ value })), + }; + const result = await h(event); + ensureResultOk(result, "set cache entry"); + }, + delete: async (key) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + const event: InternalEvent = { + type: "core", + method: "DELETE", + rawPath: `/cache/${encodedKey}`, + url, + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; + const result = await h(event); + ensureResultOk(result, "delete cache entry"); + }, + revalidateTags: async (tags) => { + const h = (await getHandler())!; + const url = `https://on/cache/revalidate-tags`; + const event: InternalEvent = { + type: "core", + method: "POST", + rawPath: `/cache/revalidate-tags`, + url, + headers: { "Content-Type": "application/json" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + body: toReadableStream(JSON.stringify({ tags })), + }; + const result = await h(event); + ensureResultOk(result, "revalidate cache tags"); + }, +}; + +export default localCache; diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 65d0a3d0..87ed3f61 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -32,6 +32,7 @@ export interface IPluginSettings { warmer?: LazyLoadedOverride | IncludedWarmer; proxyExternalRequest?: OverrideOptions["proxyExternalRequest"]; cdnInvalidation?: OverrideOptions["cdnInvalidation"]; + cache?: OverrideOptions["cache"]; }; defaultOverrides?: DefaultOverrides; fnName?: string; @@ -49,6 +50,7 @@ const nameToFolder = { warmer: "warmer", proxyExternalRequest: "proxyExternalRequest", cdnInvalidation: "cdnInvalidation", + cache: "cache", }; export type OverrideKey = keyof typeof nameToFolder; @@ -72,6 +74,7 @@ export type BundleType = | "imageOptimization" | "revalidation" | "warmer" + | "cache" | "tagCache"; export type BundleDefaults = Partial>; diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 289fe144..4694e9ff 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -4,6 +4,7 @@ import type { OutgoingHttpHeaders } from "node:http"; import type { AssetResolver, CDNInvalidationHandler, + Cache, IncrementalCache, ProxyExternalRequest, Queue, @@ -195,6 +196,13 @@ declare global { */ var openNextVersion: string; + /** + * The cache client used to communicate with the cache handler function. + * Only available in main functions. + * Defined in `createMainHandler`. + */ + var cache: Cache | undefined; + /** * The function that is used when resolving external rewrite requests. * Only available in main functions diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index ead9ce6e..fe65d04b 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -6,6 +6,7 @@ import type { WarmerEvent, WarmerResponse } from "../adapters/warmer-function"; import type { AssetResolver, CDNInvalidationHandler, + Cache, Converter, ImageLoader, IncrementalCache, @@ -204,6 +205,8 @@ export type IncludedOriginResolver = "pattern-env" | "dummy"; export type IncludedWarmer = "aws-lambda" | "dummy"; +export type IncludedCache = "fetch" | "local" | "dummy"; + export type IncludedProxyExternalRequest = "node" | "fetch" | "dummy"; export type IncludedCDNInvalidationHandler = "cloudfront" | "dummy"; @@ -263,6 +266,13 @@ export interface OverrideOptions extends DefaultOverrideOptions { * @default "dummy" */ cdnInvalidation?: IncludedCDNInvalidationHandler | LazyLoadedOverride; + + /** + * Add possibility to override the default cache client used to communicate with the cache handler function. + * Can be used to connect to a remote cache handler via HTTP fetch or to use a direct in-process cache. + * @default undefined - Falls back to using the incremental cache directly. + */ + cache?: IncludedCache | LazyLoadedOverride; } export interface InstallOptions { @@ -465,6 +475,29 @@ export interface OpenNextConfig { tagCache?: IncludedTagCache | LazyLoadedOverride; }; + /** + * Override the default cache handler function. + * By default, works on lambda. + * Supports only node runtime + */ + cacheHandler?: DefaultFunctionOptions & { + /** + * Override the default incremental cache. + * @default "s3" + */ + incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; + /** + * Override the default tag cache. + * @default "dynamodb" + */ + tagCache?: IncludedTagCache | LazyLoadedOverride; + /** + * Override the default cdn invalidation handler for on demand revalidation. + * @default "dummy" + */ + cdnInvalidation?: IncludedCDNInvalidationHandler | LazyLoadedOverride; + }; + /** * Dangerous options. This break some functionnality but can be useful in some cases. */ diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index a5cd7e1c..64a6f5e9 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -282,6 +282,20 @@ export type ProxyExternalRequest = BaseOverride & { proxy: (event: InternalEvent) => Promise; }; +export type Cache = BaseOverride & { + get( + key: string, + cacheType?: CacheType + ): Promise> | null>; + set( + key: string, + value: CacheValue, + isFetch?: CacheType + ): Promise; + delete(key: string): Promise; + revalidateTags(tags: string[]): Promise; +}; + type CDNPath = { initialPath: string; rawPath: string; diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts new file mode 100644 index 00000000..975c22b2 --- /dev/null +++ b/packages/core/src/utils/cache-get.ts @@ -0,0 +1,196 @@ +import type { StoredComposableCacheEntry } from "@/types/cache"; +import type { CachedFile, CachedFetchValue, WithLastModified } from "@/types/overrides"; + +type HeadersMap = Record; + +type Base = { + lastModified?: number; + shouldBypassTagCache?: boolean; +}; + +function getHeaderValue(headers: HeadersMap, name: string): string | undefined { + const v = headers[name]; + if (typeof v === "string") return v; + if (Array.isArray(v) && v.length > 0) return v[0]; + return undefined; +} + +function getHeaderNumber(headers: HeadersMap, name: string): number | undefined { + const v = getHeaderValue(headers, name); + if (v === undefined) return undefined; + const n = Number(v); + return Number.isNaN(n) ? undefined : n; +} + +/** + * Parses a cache revalidation header. + * + * @param headers Cache response headers. + * @param name Revalidation header name. + * @return A numeric interval, false for permanent entries, or undefined for invalid values. + */ +function getHeaderRevalidate(headers: HeadersMap, name: string): number | false | undefined { + const value = getHeaderValue(headers, name); + return value === "false" ? false : getHeaderNumber(headers, name); +} + +function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(prefix)) { + const originalName = key.slice(prefix.length); + result[originalName] = value; + } + } + return result; +} + +export function parseCacheGetResponse( + headers: HeadersMap, + bodyText: string +): WithLastModified< + | (CachedFile & { revalidate?: number | false }) + | (CachedFetchValue & { revalidate?: number | false }) + | (StoredComposableCacheEntry & { revalidate?: number | false }) +> | null { + const found = getHeaderValue(headers, "x-opennext-cache-found"); + if (found !== "true") return null; + + const cacheType = getHeaderValue(headers, "x-opennext-cache-type"); + const lastModified = getHeaderNumber(headers, "x-opennext-cache-last-modified"); + const shouldBypass = getHeaderValue(headers, "x-opennext-cache-should-bypass") === "true"; + + const base: Base = { + ...(lastModified !== undefined ? { lastModified } : {}), + ...(shouldBypass ? { shouldBypassTagCache: true as const } : {}), + }; + + if (cacheType === "composable") { + return reconstructComposable(headers, bodyText, base); + } + + if (cacheType === "fetch") { + return reconstructFetch(headers, bodyText, base); + } + + return reconstructCachedFile(headers, bodyText, base); +} + +function reconstructComposable(headers: HeadersMap, bodyText: string, base: Base) { + const stale = getHeaderNumber(headers, "x-opennext-cache-composable-stale"); + const expire = getHeaderNumber(headers, "x-opennext-cache-composable-expire"); + const timestamp = getHeaderNumber(headers, "x-opennext-cache-composable-timestamp"); + const revalidate = getHeaderNumber(headers, "x-opennext-cache-composable-revalidate"); + const tagsStr = getHeaderValue(headers, "x-opennext-cache-composable-tags"); + const tags = tagsStr ? JSON.parse(tagsStr) : []; + + if (stale === undefined || expire === undefined || timestamp === undefined || revalidate === undefined) { + return null; + } + + return { + value: { + value: bodyText, + tags, + stale, + expire, + timestamp, + revalidate, + } satisfies StoredComposableCacheEntry, + ...base, + }; +} + +function reconstructFetch(headers: HeadersMap, bodyText: string, base: Base) { + const kind = getHeaderValue(headers, "x-opennext-cache-fetch-kind"); + if (kind !== "FETCH") return null; + + const url = getHeaderValue(headers, "x-opennext-cache-fetch-data-url") ?? ""; + const status = getHeaderNumber(headers, "x-opennext-cache-fetch-data-status"); + const dataTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-data-tags"); + const dataTags = dataTagsStr ? JSON.parse(dataTagsStr) : undefined; + const fetchTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-tags"); + const fetchTags = fetchTagsStr ? JSON.parse(fetchTagsStr) : undefined; + const revalidate = getHeaderRevalidate(headers, "x-opennext-cache-revalidate"); + + const dataHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-") as Record; + + const value: CachedFetchValue & { revalidate?: number | false } = { + kind: "FETCH", + data: { + headers: dataHeaders, + body: bodyText, + url, + ...(status !== undefined ? { status } : {}), + ...(dataTags !== undefined ? { tags: dataTags } : {}), + }, + ...(fetchTags !== undefined ? { tags: fetchTags } : {}), + ...(revalidate !== undefined ? { revalidate } : {}), + }; + + return { value, ...base }; +} + +function reconstructCachedFile(headers: HeadersMap, bodyText: string, base: Base) { + const subType = getHeaderValue(headers, "x-opennext-cache-sub-type"); + const metaStatus = getHeaderNumber(headers, "x-opennext-cache-meta-status"); + const metaPostponed = getHeaderValue(headers, "x-opennext-cache-meta-postponed"); + const revalidate = getHeaderRevalidate(headers, "x-opennext-cache-revalidate"); + + const metaHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-"); + const hasMetaHeaders = Object.keys(metaHeaders).length > 0; + + const meta: Record = {}; + if (metaStatus !== undefined) meta.status = metaStatus; + if (metaPostponed !== undefined) meta.postponed = metaPostponed; + if (hasMetaHeaders) meta.headers = metaHeaders; + + const hasMeta = metaStatus !== undefined || metaPostponed !== undefined || hasMetaHeaders; + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const extra: Record = {}; + if (hasMeta) extra.meta = meta; + if (revalidate !== undefined) extra.revalidate = revalidate; + + switch (subType) { + case "route": { + return { + value: { type: "route", body: bodyText, ...extra } satisfies CachedFile, + ...base, + }; + } + case "page": { + const parsed = JSON.parse(bodyText) as { json: object; html: string }; + return { + value: { type: "page", html: parsed.html, json: parsed.json, ...extra } satisfies CachedFile, + ...base, + }; + } + case "app": { + const parsed = JSON.parse(bodyText) as { + html: string; + rsc: string; + segmentData?: Record; + }; + return { + value: { + type: "app", + html: parsed.html, + rsc: parsed.rsc, + ...(parsed.segmentData ? { segmentData: parsed.segmentData } : {}), + ...extra, + } satisfies CachedFile, + ...base, + }; + } + case "redirect": { + const props = JSON.parse(bodyText); + return { + value: { type: "redirect", props, ...extra } satisfies CachedFile, + ...base, + }; + } + default: + return null; + } +} diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts new file mode 100644 index 00000000..4780e7ad --- /dev/null +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -0,0 +1,95 @@ +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(), + }, +})); + +vi.mock("@opennextjs/core/core/createGenericHandler", () => ({ + createGenericHandler: vi.fn(async ({ handler }) => handler), +})); + +vi.mock("@opennextjs/core/core/resolve", () => ({ + resolveIncrementalCache: vi.fn().mockResolvedValue(mocks.incrementalCache), + resolveTagCache: vi.fn().mockResolvedValue(mocks.tagCache), + resolveCdnInvalidation: vi.fn().mockResolvedValue(mocks.cdnInvalidationHandler), +})); + +vi.mock("@opennextjs/core/utils/cache", () => ({ + writeTags: vi.fn(), +})); + +function createEvent(method: string, rawPath: string, body?: string): InternalEvent { + return { + type: "core", + method, + rawPath, + url: `https://cache.test${rawPath}`, + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + body: body === undefined ? undefined : toReadableStream(body), + }; +} + +describe("cache adapter stream bodies", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.openNextConfig = { default: {} }; + }); + + test("preserves existing request context storage", async () => { + const requestStorage = new AsyncLocalStorage(); + globalThis.__openNextAls = requestStorage; + vi.resetModules(); + + await import("@opennextjs/core/adapters/cache-handler"); + + 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 }))); + + expect(result.statusCode).toBe(200); + expect(mocks.incrementalCache.set).toHaveBeenCalledWith("key", value, "cache"); + }); + + test("revalidates tags from a streamed request body", async () => { + const result = await handler( + createEvent("POST", "/cache/revalidate-tags", JSON.stringify({ tags: ["one", "two"] })) + ); + + expect(result.statusCode).toBe(200); + expect(mocks.tagCache.getPathsByTags).toHaveBeenCalledWith(["one", "two"]); + }); + + test("rejects non-string tags", async () => { + const result = await handler( + createEvent("POST", "/cache/revalidate-tags", JSON.stringify({ tags: ["one", 2] })) + ); + + expect(result.statusCode).toBe(400); + expect(mocks.tagCache.getPathsByTags).not.toHaveBeenCalled(); + expect(mocks.tagCache.writeTags).not.toHaveBeenCalled(); + expect(mocks.cdnInvalidationHandler.invalidatePaths).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 347f35c1..c79589db 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -28,6 +28,13 @@ describe("CacheHandler", () => { 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", @@ -69,10 +76,50 @@ 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({}); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 35b723b4..6e0ce2b2 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -24,6 +24,13 @@ describe("Composable cache handler", () => { 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", @@ -64,6 +71,55 @@ 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", () => { diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index f545406b..7d7086ef 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -53,6 +53,14 @@ const incrementalCache = { 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(), @@ -85,6 +93,7 @@ beforeEach(() => { disableIncrementalCache: false, }, }; + globalThis.cache = undefined; }); describe("cacheInterceptor", () => { @@ -151,6 +160,22 @@ 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", diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts new file mode 100644 index 00000000..8da313b6 --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -0,0 +1,225 @@ +import fetchCache from "@opennextjs/core/overrides/cache/fetch"; +import { vi, describe, expect, it, beforeEach, afterEach } from "vitest"; + +// Helper: convert a plain headers object to a Map (mimics Headers API) +function toHeadersMap(headers: Record): Map { + const map = new Map(); + for (const [key, value] of Object.entries(headers)) { + map.set(key, value); + } + return map; +} + +function mockFetch(resp: { headers: Record; body: string; status?: number }) { + const status = resp.status ?? 200; + const response = { + ok: status >= 200 && status < 300, + status, + text: vi.fn().mockResolvedValue(resp.body), + headers: toHeadersMap(resp.headers), + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); +} + +describe("fetch cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch({ headers: {}, body: "" }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should have name 'fetch-cache'", () => { + expect(fetchCache.name).toBe("fetch-cache"); + }); + + describe("get", () => { + it("should make a GET request to the correct URL", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("my-key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/my-key", { method: "GET" }); + }); + + it("should encode the key in the URL", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("special/key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/special%2Fkey", { method: "GET" }); + }); + + it("should add type query param when cacheType is provided", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("key", "fetch"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=fetch", { method: "GET" }); + }); + + it("should return null when x-opennext-cache-found is not true", async () => { + // No x-opennext-cache-found header → parseCacheGetResponse returns null + mockFetch({ headers: { "content-type": "text/plain" }, body: "" }); + + const result = await fetchCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should return null for cache miss (found = false)", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "false", + "x-opennext-cache-type": "cache", + "Cache-Control": "no-store", + }, + body: "", + }); + + const result = await fetchCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should reconstruct a route cache entry from the response", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1000", + "Cache-Control": "no-store", + }, + body: "route-body-content", + }); + + const result = await fetchCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "route-body-content", + }); + expect(result!.lastModified).toBe(1000); + }); + + it("should reconstruct a fetch cache entry from the response", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "Cache-Control": "no-store", + }, + body: '{"data":"value"}', + }); + + const result = await fetchCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ + kind: "FETCH", + data: { + url: "https://example.com", + status: 200, + body: '{"data":"value"}', + }, + }); + }); + + it("should pass response headers (as plain object) and body text to parseCacheGetResponse", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "Content-Type": "text/plain", + }, + body: "hello", + }); + + const result = await fetchCache.get("key"); + + // parseCacheGetResponse receives the headers as a plain Record + // and the body text, then returns the parsed result + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ type: "route", body: "hello" }); + }); + }); + + describe("set", () => { + it("should make a PUT request with JSON body", async () => { + const value = { type: "route", body: "content" }; + await fetchCache.set("key", value); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }); + + it("should encode the key in the URL", async () => { + await fetchCache.set("special/key", {}); + + expect(global.fetch).toHaveBeenCalledWith("/cache/special%2Fkey", expect.any(Object)); + }); + + // Incremental caches may key entries on the cache type, writing without it would store + // the entry where `get` does not look for it. + it("should forward the cache type", async () => { + await fetchCache.set("key", {}, "composable"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=composable", expect.any(Object)); + }); + + it("should reject an unsuccessful response", async () => { + mockFetch({ headers: {}, body: "", status: 500 }); + + await expect(fetchCache.set("key", {})).rejects.toThrow( + "Failed to set cache entry: cache handler returned 500" + ); + }); + }); + + describe("delete", () => { + it("should make a DELETE request", async () => { + await fetchCache.delete("key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key", { method: "DELETE" }); + }); + + it("should reject an unsuccessful response", async () => { + mockFetch({ headers: {}, body: "", status: 500 }); + + await expect(fetchCache.delete("key")).rejects.toThrow( + "Failed to delete cache entry: cache handler returned 500" + ); + }); + }); + + describe("revalidateTags", () => { + it("should make a POST request with tags body", async () => { + await fetchCache.revalidateTags(["tag1", "tag2"]); + + expect(global.fetch).toHaveBeenCalledWith("/cache/revalidate-tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags: ["tag1", "tag2"] }), + }); + }); + + it("should reject an unsuccessful response", async () => { + mockFetch({ headers: {}, body: "", status: 500 }); + + await expect(fetchCache.revalidateTags(["tag"])).rejects.toThrow( + "Failed to revalidate cache tags: cache handler returned 500" + ); + }); + }); +}); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts new file mode 100644 index 00000000..45eb1f8a --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -0,0 +1,252 @@ +import localCache from "@opennextjs/core/overrides/cache/local"; +import type { InternalResult } from "@opennextjs/core/types/open-next"; +import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; +import { vi, describe, expect, it, beforeEach } from "vitest"; + +vi.mock("@opennextjs/core/utils/normalize-path", () => ({ + getMonorepoRelativePath: vi.fn().mockReturnValue("/mock/root"), +})); + +const mockHandler = vi.fn(); + +vi.mock("/mock/root/cache-function/handler.mjs", () => ({ + handler: mockHandler, +})); + +function createMockResult(overrides: Partial & { bodyText?: string } = {}): InternalResult { + const { bodyText, ...rest } = overrides; + return { + type: "core", + statusCode: 200, + body: toReadableStream(bodyText ?? ""), + isBase64Encoded: false, + headers: {}, + ...rest, + }; +} + +describe("local cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should have name 'local-cache'", () => { + expect(localCache.name).toBe("local-cache"); + }); + + describe("get", () => { + it("should construct a GET InternalEvent with the correct rawPath", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("my-key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("GET"); + expect(event.rawPath).toBe("/cache/my-key"); + expect(event.url).toBe("https://on/cache/my-key"); + }); + + it("should encode the key in rawPath and url", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("special/key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.rawPath).toBe("/cache/special%2Fkey"); + expect(event.url).toBe("https://on/cache/special%2Fkey"); + }); + + it("should add type query param when cacheType is provided", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("key", "fetch"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.query).toEqual({ type: "fetch" }); + }); + + it("should return null when x-opennext-cache-found is missing", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: { "content-type": "text/plain" } }) + ); + + const result = await localCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should return null when a cache miss has no body", async () => { + mockHandler.mockResolvedValue( + createMockResult({ body: undefined, headers: { "x-opennext-cache-found": "false" } }) + ); + + await expect(localCache.get("key")).resolves.toBeNull(); + }); + + it("should return null for cache miss (found = false)", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: "", + headers: { "x-opennext-cache-found": "false", "Cache-Control": "no-store" }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should reconstruct a route cache entry from handler result", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: "route-body", + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1000", + "Cache-Control": "no-store", + "Content-Type": "text/plain", + }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ type: "route", body: "route-body" }); + expect(result!.lastModified).toBe(1000); + }); + + it("should reconstruct a fetch cache entry from handler result", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: '{"data":"value"}', + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "Cache-Control": "no-store", + }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ + kind: "FETCH", + data: { + url: "https://example.com", + status: 200, + body: '{"data":"value"}', + }, + }); + }); + }); + + describe("set", () => { + it("should construct a PUT InternalEvent with JSON body", async () => { + mockHandler.mockResolvedValue(createMockResult()); + const value = { type: "route", body: "content" }; + + await localCache.set("key", value); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("PUT"); + expect(event.rawPath).toBe("/cache/key"); + expect(event.headers).toEqual({ "Content-Type": "application/json" }); + expect(await fromReadableStream(event.body)).toBe(JSON.stringify({ value })); + }); + + it("should encode the key", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.set("special/key", {}); + + const event = mockHandler.mock.calls[0][0]; + expect(event.rawPath).toBe("/cache/special%2Fkey"); + }); + + // Incremental caches may key entries on the cache type, writing without it would store + // the entry where `get` does not look for it. + it("should forward the cache type", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.set("key", {}, "composable"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.query).toEqual({ type: "composable" }); + }); + + it("should reject an unsuccessful response", async () => { + mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); + + await expect(localCache.set("key", {})).rejects.toThrow( + "Failed to set cache entry: cache handler returned 500" + ); + }); + }); + + describe("delete", () => { + it("should construct a DELETE InternalEvent", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.delete("key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("DELETE"); + expect(event.rawPath).toBe("/cache/key"); + }); + + it("should reject an unsuccessful response", async () => { + mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); + + await expect(localCache.delete("key")).rejects.toThrow( + "Failed to delete cache entry: cache handler returned 500" + ); + }); + }); + + describe("revalidateTags", () => { + it("should construct a POST InternalEvent with tags body", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.revalidateTags(["tag1", "tag2"]); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("POST"); + expect(event.rawPath).toBe("/cache/revalidate-tags"); + expect(await fromReadableStream(event.body)).toBe(JSON.stringify({ tags: ["tag1", "tag2"] })); + }); + + it("should reject an unsuccessful response", async () => { + mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); + + await expect(localCache.revalidateTags(["tag"])).rejects.toThrow( + "Failed to revalidate cache tags: cache handler returned 500" + ); + }); + }); + + describe("handler caching", () => { + it("should reuse the handler across multiple calls", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("key1"); + await localCache.get("key2"); + + expect(mockHandler).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts new file mode 100644 index 00000000..b7fe39c2 --- /dev/null +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -0,0 +1,387 @@ +import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get"; +import { describe, expect, it } from "vitest"; + +describe("parseCacheGetResponse", () => { + it("should return null when x-opennext-cache-found is not 'true'", () => { + const result = parseCacheGetResponse({ "x-opennext-cache-type": "cache" }, "body"); + expect(result).toBeNull(); + }); + + it("should return null when x-opennext-cache-found is 'false'", () => { + const result = parseCacheGetResponse( + { "x-opennext-cache-found": "false", "x-opennext-cache-type": "cache" }, + "body" + ); + expect(result).toBeNull(); + }); + + describe("composable", () => { + it("should reconstruct a composable cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + "x-opennext-cache-composable-expire": "200", + "x-opennext-cache-composable-timestamp": "300", + "x-opennext-cache-composable-revalidate": "400", + "x-opennext-cache-composable-tags": '["tag1","tag2"]', + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + value: "test-value", + tags: ["tag1", "tag2"], + stale: 100, + expire: 200, + timestamp: 300, + revalidate: 400, + }); + }); + + it("should return null when required composable fields are missing", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).toBeNull(); + }); + + it("should handle empty tags array in composable entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + "x-opennext-cache-composable-expire": "200", + "x-opennext-cache-composable-timestamp": "300", + "x-opennext-cache-composable-revalidate": "400", + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + value: "test-value", + tags: [], + stale: 100, + expire: 200, + timestamp: 300, + revalidate: 400, + }); + }); + }); + + describe("fetch", () => { + it("should reconstruct a fetch cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "x-opennext-cache-fetch-data-tags": '["tag1"]', + "x-opennext-cache-fetch-tags": '["tag2"]', + "x-opennext-cache-revalidate": "60", + "x-opennext-cache-header-content-type": "application/json", + }; + const result = parseCacheGetResponse(headers, '{"data":"value"}'); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + kind: "FETCH", + data: { + headers: { "content-type": "application/json" }, + body: '{"data":"value"}', + url: "https://example.com", + status: 200, + tags: ["tag1"], + }, + tags: ["tag2"], + revalidate: 60, + }); + }); + + it("should return null when fetch kind is not FETCH", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "OTHER", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).toBeNull(); + }); + + it("should handle fetch entry without optional fields", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + kind: "FETCH", + data: { + headers: {}, + body: "body", + url: "", + }, + }); + }); + + it("should preserve a false revalidation value", () => { + const result = parseCacheGetResponse( + { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-revalidate": "false", + }, + "body" + ); + + expect(result?.value).toMatchObject({ revalidate: false }); + }); + + it("should collect prefixed headers", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-header-content-type": "text/plain", + "x-opennext-cache-header-x-custom": "custom-value", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + const value = result!.value as { data: { headers: Record } }; + expect(value.data.headers).toEqual({ + "content-type": "text/plain", + "x-custom": "custom-value", + }); + }); + + it("should handle array-valued headers", () => { + const headers: Record = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-header-set-cookie": ["cookie1", "cookie2"], + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + const value = result!.value as { data: { headers: Record } }; + expect(value.data.headers["set-cookie"]).toEqual(["cookie1", "cookie2"]); + }); + }); + + describe("cached file", () => { + it("should reconstruct a route cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-meta-status": "200", + "x-opennext-cache-revalidate": "300", + }; + const result = parseCacheGetResponse(headers, "route body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "route body", + meta: { status: 200 }, + revalidate: 300, + }); + }); + + it("should preserve a false revalidation value", () => { + const result = parseCacheGetResponse( + { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-revalidate": "false", + }, + "route body" + ); + + expect(result?.value).toMatchObject({ revalidate: false }); + }); + + it("should reconstruct a page cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "page", + }; + const body = JSON.stringify({ html: "", json: { data: "value" } }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "page", + html: "", + json: { data: "value" }, + }); + }); + + it("should reconstruct an app cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "app", + }; + const body = JSON.stringify({ + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "app", + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }); + }); + + it("should reconstruct an app cache entry without segmentData", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "app", + }; + const body = JSON.stringify({ html: "", rsc: "rsc-data" }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "app", + html: "", + rsc: "rsc-data", + }); + }); + + it("should reconstruct a redirect cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "redirect", + }; + const body = JSON.stringify({ destination: "/new-path" }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "redirect", + props: { destination: "/new-path" }, + }); + }); + + it("should return null for unknown sub-type", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "unknown-type", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).toBeNull(); + }); + + it("should handle meta with postponed field", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-meta-postponed": "postponed-data", + "x-opennext-cache-header-content-type": "text/html", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "body", + meta: { + postponed: "postponed-data", + headers: { "content-type": "text/html" }, + }, + }); + }); + }); + + describe("base metadata", () => { + it("should include lastModified when x-opennext-cache-last-modified is present", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1234567890", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBe(1234567890); + }); + + it("should include shouldBypassTagCache when header is true", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-should-bypass": "true", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.shouldBypassTagCache).toBe(true); + }); + + it("should not include shouldBypassTagCache when header is not 'true'", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-should-bypass": "false", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.shouldBypassTagCache).toBeUndefined(); + }); + + it("should handle missing lastModified gracefully", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBeUndefined(); + }); + + it("should handle invalid lastModified number gracefully", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "not-a-number", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBeUndefined(); + }); + }); +});