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 e52dd92b..1c826183 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", }, }, @@ -23,6 +22,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 a89d527e..4496c30f 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -1,147 +1,46 @@ -import fs from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; - -import { compileCache } from "@opennextjs/core/build/compileCache.js"; -import { compileOpenNextConfig } from "@opennextjs/core/build/compileConfig.js"; -import { compileTagCacheProvider } from "@opennextjs/core/build/compileTagCacheProvider.js"; -import { createCacheAssets, createStaticAssets } from "@opennextjs/core/build/createAssets.js"; -import { createImageOptimizationBundle } from "@opennextjs/core/build/createImageOptimizationBundle.js"; -import { createMiddleware } from "@opennextjs/core/build/createMiddleware.js"; -import { createRevalidationBundle } from "@opennextjs/core/build/createRevalidationBundle.js"; -import { createServerBundle } from "@opennextjs/core/build/createServerBundle.js"; -import { createWarmerBundle } from "@opennextjs/core/build/createWarmerBundle.js"; -import { generateOutput } from "@opennextjs/core/build/generateOutput.js"; +import { buildAdapter } from "@opennextjs/core/build/adapter.js"; +import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { addDebugFile } from "@opennextjs/core/debug.js"; import type { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; import { externalChunksPlugin, inlineRouteHandler } from "@opennextjs/core/plugins/inlineRouteHandlers.js"; -import type { NextConfig } from "@opennextjs/core/types/next-types.js"; - -export type NextAdapterOutput = { - pathname: string; - filePath: string; - assets: Record; -}; - -export type NextAdapterOutputs = { - pages: NextAdapterOutput[]; - pagesApi: NextAdapterOutput[]; - appPages: NextAdapterOutput[]; - appRoutes: NextAdapterOutput[]; - middleware?: NextAdapterOutput; -}; - -type NextAdapter = { - name: string; - modifyConfig: (config: NextConfig, { phase }: { phase: string }) => Promise; - onBuildComplete: (props: { - routes: unknown; - outputs: NextAdapterOutputs; - projectDir: string; - repoRoot: string; - distDir: string; - config: NextConfig; - nextVersion: string; - }) => Promise; -}; //TODO: use the one provided by Next - -let buildOpts: buildHelper.BuildOptions; - -export default { - name: "OpenNext", - async modifyConfig(nextConfig, { phase }) { - // We have to precompile the cache here, probably compile OpenNext config as well - const { config, buildDir } = await compileOpenNextConfig("open-next.config.ts", { - nodeExternals: undefined, - }); - - const require = createRequire(import.meta.url); - //TODO: change that - const openNextDistDir = path.dirname(require.resolve("@opennextjs/core/debug.js")); - - buildOpts = buildHelper.normalizeOptions(config, openNextDistDir, buildDir); - - buildHelper.initOutputDir(buildOpts); - - const cache = compileCache(buildOpts); - - const packagePath = buildHelper.getPackagePath(buildOpts); - - // We then have to copy the cache files to the .next dir so that they are available at runtime - //TODO: use a better path, this one is temporary just to make it work - const tempCachePath = path.join( - buildOpts.outputDir, - "server-functions/default", - packagePath, - ".open-next/.build" - ); - fs.mkdirSync(tempCachePath, { recursive: true }); - fs.copyFileSync(cache.cache, path.join(tempCachePath, "cache.cjs")); - fs.copyFileSync(cache.composableCache, path.join(tempCachePath, "composable-cache.cjs")); - - //TODO: We should check the version of Next here, below 16 we'd throw or show a warning - return { - ...nextConfig, - cacheHandler: cache.cache, //TODO: compute that here, - cacheHandlers: { - default: cache.composableCache, - remote: cache.composableCache, - }, - cacheMaxMemorySize: 0, - experimental: { - ...nextConfig.experimental, - trustHostHeader: true, - }, - }; +import type { NextAdapterOutputs } from "@opennextjs/core/types/adapter.js"; + +export default buildAdapter((_config, buildOpts: BuildOptions) => ({ + defaultOverrides: { + server: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.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", + converter: "@opennextjs/aws/overrides/converters/sqs-revalidate.js", + }, + imageOptimization: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", + imageLoader: "@opennextjs/aws/overrides/imageLoader/s3.js", + }, + warmer: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js" }, + tagCache: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + }, + 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", + queue: "@opennextjs/aws/overrides/queue/sqs-lite.js", + }, }, - async onBuildComplete(outputs) { - console.log("OpenNext build will start now"); - - // TODO(vicb): save outputs - addDebugFile(buildOpts, "outputs.json", outputs); - - // Compile middleware - await createMiddleware(buildOpts); - console.log("Middleware created"); - - createStaticAssets(buildOpts); - console.log("Static assets created"); - - if (buildOpts.config.dangerous?.disableIncrementalCache !== true) { - const { useTagCache } = createCacheAssets(buildOpts); - console.log("Cache assets created"); - if (useTagCache) { - await compileTagCacheProvider(buildOpts); - console.log("Tag cache provider compiled"); - } - } - - await createServerBundle( - buildOpts, - { - additionalPlugins: getAdditionalPluginsFactory(buildOpts, outputs.outputs), - }, - outputs.outputs - ); - - console.log("Server bundle created"); - await createRevalidationBundle(buildOpts); - console.log("Revalidation bundle created"); - await createImageOptimizationBundle(buildOpts); - console.log("Image optimization bundle created"); - await createWarmerBundle(buildOpts); - console.log("Warmer bundle created"); - await generateOutput(buildOpts); - console.log("Output generated"); + serverBundle: { + externals: ["./middleware.mjs"], + additionalPlugins: (updater: ContentUpdater, outputs: NextAdapterOutputs) => { + const packagePath = buildHelper.getPackagePath(buildOpts); + return [inlineRouteHandler(updater, outputs, packagePath), externalChunksPlugin(outputs, packagePath)]; + }, }, -} satisfies NextAdapter; - -function getAdditionalPluginsFactory(buildOpts: buildHelper.BuildOptions, outputs: NextAdapterOutputs) { - //TODO: we should make this a property of buildOpts - const packagePath = buildHelper.getPackagePath(buildOpts); - return (updater: ContentUpdater) => [ - inlineRouteHandler(updater, outputs, packagePath), - externalChunksPlugin(outputs, packagePath), - ]; -} +})); diff --git a/packages/aws/src/build.ts b/packages/aws/src/build.ts index d74befcd..ade47ee8 100755 --- a/packages/aws/src/build.ts +++ b/packages/aws/src/build.ts @@ -3,18 +3,8 @@ import path from "node:path"; import url from "node:url"; import { buildNextjsApp, setStandaloneBuildMode } from "@opennextjs/core/build/buildNextApp.js"; -import { compileCache } from "@opennextjs/core/build/compileCache.js"; import { compileOpenNextConfig } from "@opennextjs/core/build/compileConfig.js"; -import { compileTagCacheProvider } from "@opennextjs/core/build/compileTagCacheProvider.js"; -import { createCacheAssets, createStaticAssets } from "@opennextjs/core/build/createAssets.js"; -import { createImageOptimizationBundle } from "@opennextjs/core/build/createImageOptimizationBundle.js"; -import { createMiddleware } from "@opennextjs/core/build/createMiddleware.js"; -import { createRevalidationBundle } from "@opennextjs/core/build/createRevalidationBundle.js"; -import { createServerBundle } from "@opennextjs/core/build/createServerBundle.js"; -import { createWarmerBundle } from "@opennextjs/core/build/createWarmerBundle.js"; -import { generateOutput } from "@opennextjs/core/build/generateOutput.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { patchOriginalNextConfig } from "@opennextjs/core/build/patch/patches/index.js"; import { printHeader, showWarningOnWindows } from "@opennextjs/core/build/utils.js"; import logger from "@opennextjs/core/logger.js"; diff --git a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index 902bc29c..417afc38 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -14,6 +14,8 @@ type DynamoDBItem = { tag?: { S: string }; path?: { S: string }; revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; type DynamoDBResponse = { @@ -56,11 +58,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -72,6 +82,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -93,7 +108,9 @@ const tagCache: OriginalTagCache = { const tags = Items?.map((item) => item.tag?.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -105,6 +122,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -121,10 +143,9 @@ const tagCache: OriginalTagCache = { throw new RecoverableError(`Failed to get by tag: ${result.status}`); } const { Items } = (await result.json()) as DynamoDBResponse; - return ( - // We need to remove the buildId from the path - Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + const paths = Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -136,6 +157,12 @@ const tagCache: OriginalTagCache = { return lastModified ?? Date.now(); } const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getLastModified"); + const cacheKey = `${key}:${lastModified ?? 0}`; + if (cache?.has(cacheKey)) { + return cache.get(cacheKey)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -156,14 +183,80 @@ const tagCache: OriginalTagCache = { } const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? []; debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0"); + } + return true; + }); + // If we have revalidated tags or expired tags we return -1 to force revalidation + const resultValue = + nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + cache?.set(cacheKey, resultValue); + return resultValue; } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, - async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) { + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" + ); + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + // We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value + const result = await awsFetch( + JSON.stringify({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + if (result.status !== 200) { + throw new RecoverableError(`Failed to check stale tags: ${result.status}`); + } + items = ((await result.json()) as DynamoDBResponse).Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, + async writeTags(tags) { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -174,7 +267,7 @@ const tagCache: OriginalTagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 88deb1a8..2b72d5c8 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { chunk, parseNumberFromEnv } from "@opennextjs/core/adapters/util.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { RecoverableError } from "@opennextjs/core/utils/error.js"; import { AwsClient } from "aws4fetch"; @@ -10,17 +10,16 @@ import { customFetchClient } from "../../utils/fetch.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants.js"; -type DynamoDBTagItem = { - revalidatedAt: { N: string }; - tag: { S: string }; -}; +let awsClient: AwsClient | null = null; -type DynamoDBBatchGetResponse = { - Responses?: Record; +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; -let awsClient: AwsClient | null = null; - const getAwsClient = () => { const { CACHE_BUCKET_REGION } = process.env; if (awsClient) { @@ -58,15 +57,84 @@ function buildDynamoKey(key: string) { // We use the same key for both path and tag // That's mostly for compatibility reason so that it's easier to use this with existing infra // FIXME: Allow a simpler object without an unnecessary path key -function buildDynamoObject(tag: string, revalidatedAt?: number) { +function buildDynamoObject(tag: string, revalidatedAt?: number, stale?: number, expire?: number) { return { path: { S: buildDynamoKey(tag) }, tag: { S: buildDynamoKey(tag) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } // This implementation does not support automatic invalidation of paths by the cdn + +/** + * Checks the items cache for each tag. Returns tags not yet cached and whether + * a positive result was already found among the cached ones. + */ +function checkItemsCache( + tags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): { uncachedTags: string[]; hasMatch: boolean } { + const uncachedTags: string[] = []; + let hasMatch = false; + for (const tag of tags) { + if (itemsCache?.has(tag)) { + if (compute(itemsCache.get(tag)!)) hasMatch = true; + } else { + uncachedTags.push(tag); + } + } + return { uncachedTags, hasMatch }; +} + +/** + * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items + * cache (storing null for absent tags), and returns whether any tag matched. + */ +async function fetchAndCacheItems( + uncachedTags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): Promise { + const { CACHE_DYNAMO_TABLE } = process.env; + const response = await awsFetch( + JSON.stringify({ + RequestItems: { + [CACHE_DYNAMO_TABLE ?? ""]: { + Keys: uncachedTags.map((tag) => ({ + path: { S: buildDynamoKey(tag) }, + tag: { S: buildDynamoKey(tag) }, + })), + }, + }, + }), + "query" + ); + if (response.status !== 200) { + throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + } + const { Responses } = await response.json(); + const responseItems: DynamoDBItem[] = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; + + // Build a lookup map: DynamoDB key → item + const responseByKey = new Map(); + for (const item of responseItems) { + responseByKey.set(item.tag?.S ?? "", item); + } + + let hasMatch = false; + for (const tag of uncachedTags) { + const item = responseByKey.get(buildDynamoKey(tag)) ?? null; + if (!item) continue; + itemsCache?.set(tag, item); + if (compute(item)) hasMatch = true; + } + return hasMatch; +} + export default { name: "ddb-nextMode", mode: "nextMode", @@ -83,38 +151,62 @@ export default { "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" ); } - const { CACHE_DYNAMO_TABLE } = process.env; + + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const now = Date.now(); + const compute = (item: DynamoDBItem): boolean => { + if (!item) return false; + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + if (expiry <= now && expiry > (lastModified ?? 0)) return true; + } + return Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + // It's unlikely that we will have more than 100 items to query // If that's the case, you should not use this tagCache implementation - const response = await awsFetch( - JSON.stringify({ - RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: { - Keys: tags.map((tag) => ({ - path: { S: buildDynamoKey(tag) }, - tag: { S: buildDynamoKey(tag) }, - })), - }, - }, - }), - "query" - ); - if (response.status !== 200) { - throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); - } - // Now we need to check for every item if lastModified is greater than the revalidatedAt - const { Responses } = (await response.json()) as DynamoDBBatchGetResponse; - if (!Responses) { + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("retrieved tags for hasBeenRevalidated", tags); + return result; + }, + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; } - const revalidatedTags = - Responses?.[CACHE_DYNAMO_TABLE ?? ""]?.filter( - (item) => Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0) - ) ?? []; - debug("retrieved tags", revalidatedTags); - return revalidatedTags.length > 0; + if (tags.length === 0) return false; + if (tags.length > 100) { + throw new RecoverableError( + "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" + ); + } + + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const compute = (item: DynamoDBItem): boolean => { + if (!item?.stale?.N) return false; + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return revalidatedAt > (lastModified ?? 0) && Number.parseInt(item.stale.N) >= (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("isStale result:", result); + return result; }, - writeTags: async (tags: string[]) => { + writeTags: async (tags) => { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -122,13 +214,18 @@ export default { } const dataChunks = chunk(tags, MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT).map((Items) => ({ RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => ({ - PutRequest: { - Item: { - ...buildDynamoObject(tag), + [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => { + const tagStr = typeof tag === "string" ? tag : tag.tag; + const stale = typeof tag === "string" ? undefined : tag.stale; + const expiry = typeof tag === "string" ? undefined : tag.expire; + return { + PutRequest: { + Item: { + ...buildDynamoObject(tagStr, undefined, stale, expiry), + }, }, - }, - })), + }; + }), }, })); const toInsert = chunk(dataChunks, getDynamoBatchWriteCommandConcurrency()); diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index 09eed484..2203dbc0 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -10,6 +10,14 @@ import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrenc const { CACHE_BUCKET_REGION, CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; +}; + function parseDynamoClientConfigFromEnv(): DynamoDBClientConfig { return { region: CACHE_BUCKET_REGION, @@ -26,11 +34,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -41,6 +57,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -57,7 +78,9 @@ const tagCache: TagCache = { const tags = result.Items?.map((item) => item.tag.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -68,6 +91,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const { Items } = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -80,10 +108,10 @@ const tagCache: TagCache = { }, }) ); - return ( - // We need to remove the buildId from the path - Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + // We need to remove the buildId from the path + const paths = Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -94,30 +122,99 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return lastModified ?? Date.now(); } - const result = await dynamoClient.send( - new QueryCommand({ - TableName: CACHE_DYNAMO_TABLE, - IndexName: "revalidate", - KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", - ExpressionAttributeNames: { - "#key": "path", - "#revalidatedAt": "revalidatedAt", - }, - ExpressionAttributeValues: { - ":key": { S: buildDynamoKey(key) }, - ":lastModified": { N: String(lastModified ?? 0) }, - }, - }) + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" ); - const revalidatedTags = result.Items ?? []; + const cacheKey = `${key}:${lastModified ?? 0}`; + let revalidatedTags: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + revalidatedTags = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + revalidatedTags = result.Items ?? []; + itemsCache?.set(cacheKey, revalidatedTags); + } debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) > now; + } + return true; + }); + + // If we have revalidated tags or expired tags we return -1 to force revalidation + return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" + ); + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + items = result.Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, async writeTags(tags) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -128,7 +225,7 @@ const tagCache: TagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index 05c11970..49efb5e3 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -1,17 +1,16 @@ -import { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; interface WithFilterOptions { /** * The original tag cache. - * Call to this will receive only the filtered tags. */ tagCache: NextModeTagCache; + /** - * The function to filter tags. - * @param tag The tag to filter. + * Filter function that returns true if the tag should be forwarded to the underlying tag cache. * @returns true if the tag should be forwarded, false otherwise. */ - filterFn: (tag: string) => boolean; + filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean; } /** @@ -60,6 +59,7 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT * This is used to filter out internal soft tags. * Can be used if `revalidatePath` is not used. */ -export function softTagFilter(tag: string): boolean { - return !tag.startsWith("_N_T_"); +export function softTagFilter(tag: string | { tag: string }): boolean { + const tagStr = typeof tag === "string" ? tag : tag.tag; + return !tagStr.startsWith("_N_T_"); } diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index ee452c1f..bfb1e073 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -1,18 +1,17 @@ /* oxlint-disable @typescript-eslint/no-explicit-any */ import fs from "node:fs"; -import { createRequire } from "node:module"; import path from "node:path"; -import { compileCache } from "@opennextjs/core/build/compileCache.js"; -import { compileOpenNextConfig } from "@opennextjs/core/build/compileConfig.js"; -import { compileTagCacheProvider } from "@opennextjs/core/build/compileTagCacheProvider.js"; -import { createCacheAssets, createStaticAssets } from "@opennextjs/core/build/createAssets.js"; -import { createMiddleware } from "@opennextjs/core/build/createMiddleware.js"; +import { buildAdapter } from "@opennextjs/core/build/adapter.js"; +import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { addDebugFile } from "@opennextjs/core/debug.js"; import type { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; +import { openNextEdgePlugins } from "@opennextjs/core/plugins/edge.js"; +import { openNextExternalMiddlewarePlugin } from "@opennextjs/core/plugins/externalMiddleware.js"; import { inlineRouteHandler } from "@opennextjs/core/plugins/inlineRouteHandlers.js"; -import type { NextConfig } from "@opennextjs/core/types/next-types.js"; +import type { NextAdapterOutputs } from "@opennextjs/core/types/adapter.js"; +import type { OpenNextConfig } from "@opennextjs/core/types/open-next.js"; +import { normalizePath } from "@opennextjs/core/utils/normalize-path.js"; import { bundleServer } from "./build/bundle-server.js"; import { compileEnvFiles } from "./build/open-next/compile-env-files.js"; @@ -20,145 +19,60 @@ import { compileImages } from "./build/open-next/compile-images.js"; import { compileInit } from "./build/open-next/compile-init.js"; import { compileSkewProtection } from "./build/open-next/compile-skew-protection.js"; import { compileDurableObjects } from "./build/open-next/compileDurableObjects.js"; -import { createServerBundle } from "./build/open-next/createServerBundle.js"; import { inlineLoadManifest } from "./build/patches/plugins/load-manifest.js"; +import { patchResRevalidate } from "./build/patches/plugins/res-revalidate.js"; +import { patchTurbopackRuntime } from "./build/patches/plugins/turbopack.js"; +import { patchUseCacheIO } from "./build/patches/plugins/use-cache.js"; -export type NextAdapterOutputs = { - pages: any[]; - pagesApi: any[]; - appPages: any[]; - appRoutes: any[]; -}; - -export type BuildCompleteCtx = { - routes: any; - outputs: NextAdapterOutputs; - projectDir: string; - repoRoot: string; - distDir: string; - config: NextConfig; - nextVersion: string; -}; - -type NextAdapter = { - name: string; - modifyConfig: (config: NextConfig, { phase }: { phase: string }) => Promise; - onBuildComplete: (ctx: BuildCompleteCtx) => Promise; -}; //TODO: use the one provided by Next - -let buildOpts: buildHelper.BuildOptions; - -export default { - name: "OpenNext", - - async modifyConfig(nextConfig) { - // We have to precompile the cache here, probably compile OpenNext config as well - const { config, buildDir } = await compileOpenNextConfig("open-next.config.ts", { - // TODO(vicb): do we need edge compile - compileEdge: true, - }); - - const require = createRequire(import.meta.url); - const openNextDistDir = path.dirname(require.resolve("@opennextjs/core/debug.js")); - - buildOpts = buildHelper.normalizeOptions(config, openNextDistDir, buildDir); - - buildHelper.initOutputDir(buildOpts); - - const cache = compileCache(buildOpts); - - // We then have to copy the cache files to the .next dir so that they are available at runtime - // TODO: use a better path, this one is temporary just to make it work - const tempCachePath = `${buildOpts.outputDir}/server-functions/default/.open-next/.build`; - fs.mkdirSync(tempCachePath, { recursive: true }); - fs.copyFileSync(cache.cache, path.join(tempCachePath, "cache.cjs")); - fs.copyFileSync(cache.composableCache, path.join(tempCachePath, "composable-cache.cjs")); - - //TODO: We should check the version of Next here, below 16 we'd throw or show a warning - return { - ...nextConfig, - cacheHandler: cache.cache, //TODO: compute that here, - cacheMaxMemorySize: 0, - cacheHandlers: { - default: cache.composableCache, - remote: cache.composableCache, - }, - experimental: { - ...nextConfig.experimental, - trustHostHeader: true, - }, - }; - }, - - async onBuildComplete(ctx: BuildCompleteCtx) { - console.log("OpenNext build will start now"); - - const configPath = path.join(buildOpts.appBuildOutputPath, ".open-next/.build/open-next.config.edge.mjs"); - if (!fs.existsSync(configPath)) { - throw new Error("Could not find compiled Open Next config, did you run the build command?"); - } - const openNextConfig = await import(configPath).then((mod) => mod.default); - - // TODO(vicb): save outputs - addDebugFile(buildOpts, "outputs.json", ctx); - - // Cloudflare specific - compileEnvFiles(buildOpts); - /* TODO(vicb): pass the wrangler config*/ - await compileInit(buildOpts, {} as any); - await compileImages(buildOpts); - await compileSkewProtection(buildOpts, openNextConfig); - - // Compile middleware - // TODO(vicb): `forceOnlyBuildOnce` is cloudflare specific - await createMiddleware(buildOpts, { forceOnlyBuildOnce: true }); - console.log("Middleware created"); - - createStaticAssets(buildOpts); - console.log("Static assets created"); - - if (buildOpts.config.dangerous?.disableIncrementalCache !== true) { - const { useTagCache } = createCacheAssets(buildOpts); - console.log("Cache assets created"); - if (useTagCache) { - await compileTagCacheProvider(buildOpts); - console.log("Tag cache provider compiled"); - } - } - - await createServerBundle( - buildOpts, - { - additionalPlugins: getAdditionalPluginsFactory(buildOpts, ctx), - }, - ctx - ); - - await compileDurableObjects(buildOpts); - - // TODO(vicb): pass minify `projectOpts` - await bundleServer(buildOpts, { minify: false } as any); - - console.log("OpenNext build complete."); - - // TODO(vicb): not needed on cloudflare - // console.log("Server bundle created"); - // await createRevalidationBundle(buildOpts); - // console.log("Revalidation bundle created"); - // await createImageOptimizationBundle(buildOpts); - // console.log("Image optimization bundle created"); - // await createWarmerBundle(buildOpts); - // console.log("Warmer bundle created"); - // await generateOutput(buildOpts); - // console.log("Output generated"); - }, -} satisfies NextAdapter; - -function getAdditionalPluginsFactory(buildOpts: buildHelper.BuildOptions, ctx: BuildCompleteCtx) { +export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => { const packagePath = buildHelper.getPackagePath(buildOpts); - return (updater: ContentUpdater) => [ - inlineRouteHandler(updater, ctx.outputs, packagePath), - //externalChunksPlugin(outputs), - inlineLoadManifest(updater, buildOpts), - ]; -} + return { + skipRevalidation: true, + skipImageOptimization: true, + skipWarmer: true, + skipGenerateOutput: true, + middlewareOptions: { forceOnlyBuildOnce: true }, + beforeServerBundle: async (buildOpts, _config) => { + // Import edge-compiled config for skew protection + const configPath = path.join( + buildOpts.appBuildOutputPath, + ".open-next/.build/open-next.config.edge.mjs" + ); + const openNextConfig = fs.existsSync(configPath) + ? await import(configPath).then((mod) => mod.default) + : config; // fallback to node config + compileEnvFiles(buildOpts); + await compileInit(buildOpts, {} as any); + await compileImages(buildOpts); + await compileSkewProtection(buildOpts, openNextConfig); + }, + serverBundle: { + useEdgeConfig: true, + externals: ["./middleware.mjs"], + banner: (name: string) => [ + `globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`, + name === "default" ? "" : `globalThis.fnName = "${name}";`, + ], + additionalPlugins: (updater: ContentUpdater, outputs: NextAdapterOutputs) => [ + inlineRouteHandler(updater, outputs, packagePath), + inlineLoadManifest(updater, buildOpts), + ...(config.middleware?.external + ? [ + openNextExternalMiddlewarePlugin( + path.join(buildOpts.openNextDistDir, "core/edgeFunctionHandler.js") + ), + ] + : []), + openNextEdgePlugins({ + nextDir: path.join(buildOpts.appBuildOutputPath, ".next"), + isInCloudflare: true, + }), + ], + additionalCodePatches: [patchResRevalidate, patchUseCacheIO, patchTurbopackRuntime], + }, + afterServerBundle: async (buildOpts, _config) => { + compileDurableObjects(buildOpts); + await bundleServer(buildOpts, { minify: false } as any); + }, + }; +}); diff --git a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts b/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts deleted file mode 100644 index d5508952..00000000 --- a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts +++ /dev/null @@ -1,342 +0,0 @@ -// Copy-Edit of @opennextjs/core packages/open-next/src/build/createServerBundle.ts -// Adapted for cloudflare workers - -import fs from "node:fs"; -import path from "node:path"; - -import { loadMiddlewareManifest } from "@opennextjs/core/adapters/config/util.js"; -import { compileCache } from "@opennextjs/core/build/compileCache.js"; -import { copyAdapterFiles } from "@opennextjs/core/build/copyAdapterFiles.js"; -import { copyMiddlewareResources, generateEdgeBundle } from "@opennextjs/core/build/edge/createEdgeBundle.js"; -import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { installDependencies } from "@opennextjs/core/build/installDeps.js"; -import type { CodePatcher } from "@opennextjs/core/build/patch/codePatcher.js"; -import { applyCodePatches } from "@opennextjs/core/build/patch/codePatcher.js"; -import * as awsPatches from "@opennextjs/core/build/patch/patches/index.js"; -import logger from "@opennextjs/core/logger.js"; -import { minifyAll } from "@opennextjs/core/minimize-js.js"; -import { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; -import { openNextEdgePlugins } from "@opennextjs/core/plugins/edge.js"; -import { openNextExternalMiddlewarePlugin } from "@opennextjs/core/plugins/externalMiddleware.js"; -import { openNextReplacementPlugin } from "@opennextjs/core/plugins/replacement.js"; -import { openNextResolvePlugin } from "@opennextjs/core/plugins/resolve.js"; -import type { FunctionOptions, SplittedFunctionOptions } from "@opennextjs/core/types/open-next.js"; -import { getCrossPlatformPathRegex } from "@opennextjs/core/utils/regex.js"; -import type { Plugin } from "esbuild"; - -import type { BuildCompleteCtx } from "../../adapter.js"; -import { normalizePath } from "../../utils/normalize-path.js"; -import { patchResRevalidate } from "../patches/plugins/res-revalidate.js"; -import { patchTurbopackRuntime } from "../patches/plugins/turbopack.js"; -import { patchUseCacheIO } from "../patches/plugins/use-cache.js"; - -interface CodeCustomization { - // These patches are meant to apply on user and next generated code - additionalCodePatches?: CodePatcher[]; - // These plugins are meant to apply during the esbuild bundling process. - // This will only apply to OpenNext code. - additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[]; -} - -export async function createServerBundle( - options: buildHelper.BuildOptions, - codeCustomization?: CodeCustomization, - /* TODO(vicb): optional to be backward compatible */ - buildCtx?: BuildCompleteCtx -) { - const { config } = options; - const foundRoutes = new Set(); - // Get all functions to build - const defaultFn = config.default; - const functions = Object.entries(config.functions ?? {}); - - // Recompile cache.ts as ESM if any function is using Deno runtime - if (defaultFn.runtime === "deno" || functions.some(([, fn]) => fn.runtime === "deno")) { - compileCache(options, "esm"); - } - - const promises = functions.map(async ([name, fnOptions]) => { - const routes = fnOptions.routes; - routes.forEach((route) => foundRoutes.add(route)); - if (fnOptions.runtime === "edge") { - await generateEdgeBundle(name, options, fnOptions); - } else { - await generateBundle(name, options, fnOptions, codeCustomization, buildCtx); - } - }); - - //TODO: throw an error if not all edge runtime routes has been bundled in a separate function - - // We build every other function than default before so we know which route there is left - await Promise.all(promises); - - const remainingRoutes = new Set(); - - const { appBuildOutputPath } = options; - - // Find remaining routes - const serverPath = path.join( - appBuildOutputPath, - ".next/standalone", - buildHelper.getPackagePath(options), - ".next/server" - ); - - // Find app dir routes - if (fs.existsSync(path.join(serverPath, "app"))) { - const appPath = path.join(serverPath, "app"); - buildHelper.traverseFiles( - appPath, - ({ relativePath }) => relativePath.endsWith("page.js") || relativePath.endsWith("route.js"), - ({ relativePath }) => { - const route = `app/${relativePath.replace(/\.js$/, "")}`; - if (!foundRoutes.has(route)) { - remainingRoutes.add(route); - } - } - ); - } - - // Find pages dir routes - if (fs.existsSync(path.join(serverPath, "pages"))) { - const pagePath = path.join(serverPath, "pages"); - buildHelper.traverseFiles( - pagePath, - ({ relativePath }) => relativePath.endsWith(".js"), - ({ relativePath }) => { - const route = `pages/${relativePath.replace(/\.js$/, "")}`; - if (!foundRoutes.has(route)) { - remainingRoutes.add(route); - } - } - ); - } - - // Generate default function - await generateBundle( - "default", - options, - { - ...defaultFn, - // @ts-expect-error - Those string are RouteTemplate - routes: Array.from(remainingRoutes), - patterns: ["*"], - }, - codeCustomization, - buildCtx - ); -} - -async function generateBundle( - name: string, - options: buildHelper.BuildOptions, - fnOptions: SplittedFunctionOptions, - codeCustomization?: CodeCustomization, - buildCtx?: BuildCompleteCtx -) { - const { appPath, appBuildOutputPath, config, outputDir, monorepoRoot } = options; - logger.info(`Building server function: ${name}...`); - - // Create output folder - const outputPath = path.join(outputDir, "server-functions", name); - - // Resolve path to the Next.js app if inside the monorepo - // note: if user's app is inside a monorepo, standalone mode places - // `node_modules` inside `.next/standalone`, and others inside - // `.next/standalone/package/path` (ie. `.next`, `server.js`). - // We need to output the handler file inside the package path. - const packagePath = buildHelper.getPackagePath(options); - const outPackagePath = path.join(outputPath, packagePath); - fs.mkdirSync(outPackagePath, { recursive: true }); - - const ext = fnOptions.runtime === "deno" ? "mjs" : "cjs"; - // Normal cache - fs.copyFileSync(path.join(options.buildDir, `cache.${ext}`), path.join(outPackagePath, "cache.cjs")); - - // Composable cache - fs.copyFileSync( - path.join(options.buildDir, `composable-cache.${ext}`), - path.join(outPackagePath, "composable-cache.cjs") - ); - - if (fnOptions.runtime === "deno") { - addDenoJson(outputPath, packagePath); - } - - // Copy middleware - if (!config.middleware?.external) { - fs.copyFileSync( - path.join(options.buildDir, "middleware.mjs"), - path.join(outPackagePath, "middleware.mjs") - ); - - const middlewareManifest = loadMiddlewareManifest(path.join(options.appBuildOutputPath, ".next")); - - copyMiddlewareResources(options, middlewareManifest.middleware["/"], outPackagePath); - } - - // Copy open-next.config.mjs - buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath, true); - - // Copy env files - buildHelper.copyEnvFile(appBuildOutputPath, packagePath, outputPath); - - let tracedFiles: string[] = []; - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - let manifests: any = {}; - - // Copy all necessary traced files - if (!buildCtx) { - throw new Error("should not happen"); - } - tracedFiles = await copyAdapterFiles(options, name, packagePath, buildCtx.outputs); - //TODO: we should load manifests here - - // TODO(vicb): what should `nodePackages` be for the adapter - // if (getOpenNextConfig(options).cloudflare?.useWorkerdCondition !== false) { - // // Next does not trace the "workerd" build condition - // // So we need to copy the whole packages using the condition - // await copyWorkerdPackages(options, nodePackages); - // } - - const additionalCodePatches = codeCustomization?.additionalCodePatches ?? []; - - await applyCodePatches(options, tracedFiles, manifests, [ - awsPatches.patchFetchCacheSetMissingWaitUntil, - awsPatches.patchFetchCacheForISR, - awsPatches.patchUnstableCacheForISR, - awsPatches.patchUseCacheForISR, - awsPatches.patchNextServer, - awsPatches.getEnvVarsPatch(options), - awsPatches.patchBackgroundRevalidation, - awsPatches.patchNodeEnvironment, - // Cloudflare specific patches - patchResRevalidate, - patchUseCacheIO, - patchTurbopackRuntime, - ...additionalCodePatches, - ]); - - // Build Lambda code - // note: bundle in OpenNext package b/c the adapter relies on the - // "serverless-http" package which is not a dependency in user's - // Next.js app. - - const overrides = fnOptions.override ?? {}; - - const disableRouting = config.middleware?.external; - - const updater = new ContentUpdater(options); - - const additionalPlugins = codeCustomization?.additionalPlugins - ? codeCustomization.additionalPlugins(updater) - : []; - - const plugins = [ - openNextReplacementPlugin({ - name: `requestHandlerOverride ${name}`, - target: getCrossPlatformPathRegex("core/requestHandler.js"), - deletes: disableRouting ? ["withRouting"] : [], - }), - - openNextResolvePlugin({ - fnName: name, - overrides, - }), - - // `openNextExternalMiddlewarePlugin` should only be used with an external middleware - ...(config.middleware?.external - ? [openNextExternalMiddlewarePlugin(path.join(options.openNextDistDir, "core/edgeFunctionHandler.js"))] - : []), - - openNextEdgePlugins({ - nextDir: path.join(options.appBuildOutputPath, ".next"), - isInCloudflare: true, - }), - ...additionalPlugins, - // The content updater plugin must be the last plugin - updater.plugin, - ]; - - const outfileExt = fnOptions.runtime === "deno" ? "ts" : "mjs"; - await buildHelper.esbuildAsync( - { - entryPoints: [path.join(options.openNextDistDir, "adapters", "server-adapter.js")], - outfile: path.join(outputPath, packagePath, `index.${outfileExt}`), - external: ["./middleware.mjs"], - banner: { - js: [ - `globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`, - name === "default" ? "" : `globalThis.fnName = "${name}";`, - ].join(""), - }, - plugins, - }, - options - ); - - const isMonorepo = monorepoRoot !== appPath; - if (isMonorepo) { - addMonorepoEntrypoint(outputPath, packagePath); - } - - installDependencies(outputPath, fnOptions.install); - - if (fnOptions.minify) { - await minifyServerBundle(outputPath); - } - - const shouldGenerateDocker = shouldGenerateDockerfile(fnOptions); - if (shouldGenerateDocker) { - fs.writeFileSync( - path.join(outputPath, "Dockerfile"), - typeof shouldGenerateDocker === "string" - ? shouldGenerateDocker - : ` -FROM node:18-alpine -WORKDIR /app -COPY . /app -EXPOSE 3000 -CMD ["node", "index.mjs"] - ` - ); - } -} - -function shouldGenerateDockerfile(options: FunctionOptions) { - return options.override?.generateDockerfile ?? false; -} - -// Add deno.json file to enable "bring your own node_modules" mode. -// TODO: this won't be necessary in Deno 2. See https://github.com/denoland/deno/issues/23151 -function addDenoJson(outputPath: string, packagePath: string) { - const config = { - // Enable "bring your own node_modules" mode - // and allow `__proto__` - unstable: ["byonm", "fs", "unsafe-proto"], - }; - fs.writeFileSync(path.join(outputPath, packagePath, "deno.json"), JSON.stringify(config, null, 2)); -} - -//TODO: check if this PR is still necessary https://github.com/opennextjs/opennextjs-aws/pull/341 -function addMonorepoEntrypoint(outputPath: string, packagePath: string) { - // Note: in the monorepo case, the handler file is output to - // `.next/standalone/package/path/index.mjs`, but we want - // the Lambda function to be able to find the handler at - // the root of the bundle. We will create a dummy `index.mjs` - // that re-exports the real handler. - - fs.writeFileSync( - path.join(outputPath, "index.mjs"), - `export { handler } from "./${normalizePath(packagePath)}/index.mjs";` - ); -} - -async function minifyServerBundle(outputDir: string) { - logger.info("Minimizing server function..."); - - await minifyAll(outputDir, { - compress_json: true, - mangle: true, - }); -} diff --git a/packages/core/package.json b/packages/core/package.json index de13a740..87e65e29 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,9 +39,12 @@ "access": "public" }, "scripts": { - "build": "tsc && tsc-alias", + "clean": "rimraf dist", + "build": "pnpm clean && tsc && tsc-alias", "dev": "concurrently \"tsc -w\" \"tsc-alias -w\"", - "ts:check": "tsc --noEmit" + "ts:check": "tsc --noEmit", + "test": "vitest --run", + "test:watch": "vitest" }, "dependencies": { "@ast-grep/napi": "^0.40.5", @@ -60,8 +63,10 @@ "@types/express": "5.0.6", "@types/node": "catalog:", "concurrently": "^9.2.1", + "rimraf": "catalog:", "tsc-alias": "^1.8.16", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "peerDependencies": { "next": "^16.0.10" diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts new file mode 100644 index 00000000..7aeae153 --- /dev/null +++ b/packages/core/src/adapters/cache-adapter.ts @@ -0,0 +1,578 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +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 { createGenericHandler } from "../core/createGenericHandler.js"; +import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; +import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; +import { runWithOpenNextRequestContext } from "../utils/promise.js"; +import { 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); + + globalThis.tagCache = await resolveTagCache(config.cacheHandler?.tagCache); + + globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( + config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation + ); + + initialized = true; +} + +///////////// +// Handler // +///////////// + +export const handler = await createGenericHandler({ + handler: defaultHandler, + type: "cache", +}); + +async function defaultHandler( + 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 cacheType: CacheEntryType = + query?.type === "fetch" ? "fetch" : query?.type === "composable" ? "composable" : "cache"; + + const additionalTags = query?.tags ? (query.tags as string).split(",") : []; + + switch (method) { + case "GET": + return await handleGet(key, cacheType, additionalTags); + 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, + additionalTags: string[] +): Promise { + debug("get", { key, cacheType, additionalTags }); + + 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", + }, + }; + } + + if (result.value && !result.shouldBypassTagCache) { + let tags: string[] = [...additionalTags]; + + if (cacheType === "cache") { + tags = [...tags, ...getTagsFromValue(result.value as CacheValue<"cache">)]; + } 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 ?? [])]; + } + + const lastModified = result.lastModified ?? Date.now(); + + if (tags.length > 0) { + const revalidated = await checkTagRevalidation(key, tags, 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", + }, + }; + } + } + + // Check if the cache entry is stale (valid but needs background revalidation) + const _isStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false; + if (_isStale) { + result.lastModified = 1; + } + } + + return buildCacheGetResponse(result); + } catch (e) { + error("Failed to get cache entry", e); + return buildErrorResponse("Failed to get cache entry", 500); + } +} + +async function checkTagRevalidation( + key: string, + tags: string[], + cacheEntry: WithLastModified> +): Promise { + if (globalThis.openNextConfig?.dangerous?.disableTagCache || 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; +} + +async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): Promise { + debug("set", { key, cacheType }); + + let payload: { + value?: Record; + } = {}; + + if (body && body.length > 0) { + try { + payload = JSON.parse(body.toString("utf-8")); + } 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); + + // 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[] = []; + + if (cacheType === "cache") { + const tags = getTagsFromValue(payload.value as CacheValue<"cache">); + derivedTags = tags; + } else if (cacheType === "fetch") { + const fetchValue = payload.value as CacheValue<"fetch">; + const data = fetchValue.data as Record | undefined; + derivedTags = (fetchValue.tags as string[]) ?? (data?.tags as string[]) ?? []; + } else if (cacheType === "composable") { + const composableValue = payload.value as CacheValue<"composable">; + derivedTags = 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: Date.now(), + })), + tagCache + ); + } + } + } + + 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); + } +} + +async function handleRevalidateTags(body?: Buffer): Promise { + debug("revalidateTags"); + + if (!body || body.length === 0) { + return buildErrorResponse("Missing request body", 400); + } + + let parsed: { tags?: string[]; durations?: { expire?: number } }; + try { + parsed = JSON.parse(body.toString("utf-8")); + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + + const tags = Array.isArray(parsed.tags) ? parsed.tags : []; + if (tags.length === 0) { + return buildErrorResponse("Missing 'tags' array in request body", 400); + } + + const { durations } = parsed; + + try { + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + if (globalThis.tagCache.mode === "nextMode") { + const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; + + const now = Date.now(); + const tagsToWrite = tags.map((tag) => { + if (durations) { + return { + tag, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { tag, expire: now }; + }); + + await writeTags(tagsToWrite); + if (paths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + paths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + return; + } + + const now = Date.now(); + for (const tag of tags) { + debug("revalidateTag", tag); + const paths = await globalThis.tagCache.getByTag(tag); + debug("Items", paths); + const toInsert = paths.map((path) => { + const baseEntry = { path, tag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }); + + 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) => { + const baseEntry = { path, tag: hardTag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }) + ); + } + } + } + + 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: CachedFetchValue, + 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.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: CachedFile, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "cache"; + headers["x-opennext-cache-sub-type"] = value.type; + + 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 0ac93ae6..6ad0b0f5 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -1,12 +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_/"; - function isFetchCache(options?: { kindHint?: "app" | "pages" | "fetch"; kind?: "FETCH" }): boolean { if (typeof options === "object") { return options.kindHint === "fetch" || options.kind === "FETCH"; @@ -29,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 globalThis.incrementalCache.get(key, "fetch"); - - if (cachedEntry?.value === undefined) return null; + const result = await globalThis.cache.get(key, "fetch", additionalTags); - const _tags = [...(tags ?? []), ...(softTags ?? [])]; - const _lastModified = cachedEntry.lastModified ?? Date.now(); - const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated(key, _tags, cachedEntry); - - if (_hasBeenRevalidated) return null; - - // 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(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 @@ -80,7 +51,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); + const cachedEntry = await globalThis.cache.get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -89,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) { @@ -174,14 +140,14 @@ export default class Cache { const detachedPromise = globalThis.__openNextAls.getStore()?.pendingPromiseRunner.withResolvers(); try { if (data === null || data === undefined) { - await globalThis.incrementalCache.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 globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "route", @@ -201,7 +167,7 @@ export default class Cache { const { html, pageData, status, headers } = data; const isAppPath = typeof pageData === "string"; if (isAppPath) { - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "app", @@ -216,7 +182,7 @@ export default class Cache { "cache" ); } else { - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "page", @@ -237,7 +203,7 @@ export default class Cache { segmentToWrite[segmentPath] = segmentContent.toString("utf8"); } } - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "app", @@ -256,10 +222,10 @@ export default class Cache { break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, "fetch"); + await globalThis.cache.set(key, data, "fetch"); break; case "REDIRECT": - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "redirect", @@ -275,7 +241,6 @@ export default class Cache { } } - await this.updateTagsOnSet(key, data, ctx); debug("Finished setting cache"); } catch (e) { error("Failed to set cache", e); @@ -285,7 +250,7 @@ export default class Cache { } } - public async revalidateTag(tags: string | string[]) { + public async revalidateTag(tags: string | string[], durations?: { expire?: number }) { const config = globalThis.openNextConfig.dangerous; if (config?.disableTagCache || config?.disableIncrementalCache) { return; @@ -296,135 +261,12 @@ export default class Cache { } try { - 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, durations); } 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 a6fb19c3..efa2c5f7 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -1,6 +1,5 @@ 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"; @@ -21,28 +20,22 @@ export default { })); } } - const result = await globalThis.incrementalCache.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; + let revalidate = result.value.revalidate; + // If the cache adapter signaled staleness via lastModified=1, trigger SWR + if (result.lastModified === 1) { + revalidate = -1; } return { ...result.value, + revalidate, value: toReadableStream(result.value.value), }; } catch (e) { @@ -61,7 +54,7 @@ export default { const entry = await promiseEntry.finally(() => { pendingWritePromiseMap.delete(cacheKey); }); - await globalThis.incrementalCache.set( + await globalThis.cache.set( cacheKey, { ...entry, @@ -69,13 +62,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() { @@ -89,12 +75,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; }, @@ -102,29 +84,28 @@ export default { * This method is only used before Next.js 16 */ async expireTags(...tags: string[]) { - if (globalThis.tagCache.mode === "nextMode") { - return writeTags(tags); + const flatTags = tags.flat(); + if (flatTags.length > 0) { + await globalThis.cache.revalidateTags(flatTags); } - 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); + }, + + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + * Mirrors the revalidateTag logic but without CDN invalidation + * since composable cache keys are not URL paths. + */ + async updateTags(tags: string[], durations?: { expire?: number }) { + if (tags.length === 0) { + return; + } + try { + await globalThis.cache.revalidateTags(tags, { + expire: durations?.expire ? Date.now() + durations.expire * 1000 : undefined, + }); + } catch (e) { + debug("Failed to update tags", e); } - 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 295ec809..340e4aab 100644 --- a/packages/core/src/adapters/middleware.ts +++ b/packages/core/src/adapters/middleware.ts @@ -11,11 +11,10 @@ import { debug, error } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; import { resolveAssetResolver, - resolveIncrementalCache, + resolveCache, resolveOriginResolver, resolveProxyRequest, resolveQueue, - resolveTagCache, } from "../core/resolve"; import { constructNextUrl } from "../core/routing/util"; import routingHandler, { @@ -40,11 +39,9 @@ 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/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts new file mode 100644 index 00000000..dfdb54f1 --- /dev/null +++ b/packages/core/src/build/adapter.spec.ts @@ -0,0 +1,541 @@ +/* eslint-disable import/first */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock node:fs to prevent actual file operations +vi.mock("node:fs", () => ({ + default: { + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + writeFileSync: vi.fn(), + }, + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +// Mock node:module to control createRequire +vi.mock("node:module", () => ({ + createRequire: vi.fn(() => ({ + resolve: vi.fn(() => "/fake/opennext/dist/debug.js"), + })), +})); + +// Mock logger to capture log calls +vi.mock("../logger.js", () => ({ + default: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock all build functions +vi.mock("./compileConfig.js", () => ({ + compileOpenNextConfig: vi.fn(), +})); + +vi.mock("./compileCache.js", () => ({ + compileCache: vi.fn(), +})); + +vi.mock("./createMiddleware.js", () => ({ + createMiddleware: vi.fn(), +})); + +vi.mock("./createAssets.js", () => ({ + createStaticAssets: vi.fn(), + createCacheAssets: vi.fn(), +})); + +vi.mock("./compileTagCacheProvider.js", () => ({ + compileTagCacheProvider: vi.fn(), +})); + +vi.mock("./createServerBundle.js", () => ({ + createServerBundle: vi.fn(), +})); + +vi.mock("./createRevalidationBundle.js", () => ({ + createRevalidationBundle: vi.fn(), +})); + +vi.mock("./createImageOptimizationBundle.js", () => ({ + createImageOptimizationBundle: vi.fn(), +})); + +vi.mock("./createWarmerBundle.js", () => ({ + createWarmerBundle: vi.fn(), +})); + +vi.mock("./generateOutput.js", () => ({ + buildOpenNextOutput: vi.fn(), + generateOutput: vi.fn(), +})); + +vi.mock("../debug.js", () => ({ + addDebugFile: vi.fn(), +})); + +vi.mock("./helper.js", () => ({ + normalizeOptions: vi.fn(), + initOutputDir: vi.fn(), + getPackagePath: vi.fn(), +})); + +import { addDebugFile } from "../debug.js"; +import logger from "../logger.js"; +import type { OpenNextConfig } from "../types/open-next.js"; + +import { buildAdapter } from "./adapter.js"; +import type { OpenNextAdapterOptions, BuildCompleteContext, NextAdapter } from "./adapter.js"; +import { compileCache } from "./compileCache.js"; +// Import mocked modules after vi.mock declarations +import { compileOpenNextConfig } from "./compileConfig.js"; +import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; +import { createStaticAssets, createCacheAssets } from "./createAssets.js"; +import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; +import { createMiddleware } from "./createMiddleware.js"; +import { createRevalidationBundle } from "./createRevalidationBundle.js"; +import { createServerBundle } from "./createServerBundle.js"; +import { createWarmerBundle } from "./createWarmerBundle.js"; +import { buildOpenNextOutput } from "./generateOutput.js"; +import * as buildHelper from "./helper.js"; +import type { BuildOptions } from "./helper.js"; + +// Helper to create mock build options +function createMockBuildOpts(): BuildOptions { + return { + appBuildOutputPath: "/app/build", + appPackageJsonPath: "/app/package.json", + appPath: "/app", + appPublicPath: "/app/public", + buildDir: "/app/.open-next/.build", + config: { + default: {}, + dangerous: {}, + } as unknown as OpenNextConfig, + debug: false, + minify: true, + monorepoRoot: "/app", + nextVersion: "16.0.0", + openNextVersion: "0.1.0", + openNextDistDir: "/fake/opennext/dist", + outputDir: "/app/.open-next", + packager: "npm" as const, + tempBuildDir: "/tmp/open-next-tmp", + } as BuildOptions; +} + +// Helper to create mock BuildCompleteContext +function createMockContext(): BuildCompleteContext { + return { + routes: [], + outputs: { + pages: [], + pagesApi: [], + appPages: [], + appRoutes: [], + }, + projectDir: "/app", + repoRoot: "/app", + distDir: "/app/.next", + config: { + experimental: {}, + images: {}, + } as BuildCompleteContext["config"], + nextVersion: "16.0.0", + }; +} + +// serverBundle.externals is mandatory for every adapter, minimal value for tests +// that don't exercise the server bundle customization. +const serverBundle = { externals: [] }; + +describe("buildAdapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Set up default mock implementations + const mockBuildOpts = createMockBuildOpts(); + + vi.mocked(compileOpenNextConfig).mockResolvedValue({ + config: { default: {}, dangerous: {} } as unknown as OpenNextConfig, + buildDir: "/tmp/open-next-tmp", + }); + + vi.mocked(buildHelper.normalizeOptions).mockReturnValue(mockBuildOpts); + vi.mocked(buildHelper.initOutputDir).mockImplementation(() => {}); + vi.mocked(buildHelper.getPackagePath).mockReturnValue(""); + + vi.mocked(compileCache).mockReturnValue({ + cache: "/tmp/cache.cjs", + composableCache: "/tmp/composable-cache.cjs", + }); + + vi.mocked(createCacheAssets).mockReturnValue({ + shouldUseTagCache: false, + metaFiles: [], + }); + }); + + test("returns an object with name, modifyConfig, and onBuildComplete", () => { + const adapter = buildAdapter(() => ({ serverBundle })); + + expect(adapter.name).toBe("OpenNext"); + expect(typeof adapter.modifyConfig).toBe("function"); + expect(typeof adapter.onBuildComplete).toBe("function"); + }); + + test("modifyConfig calls compileOpenNextConfig with compileEdge: true, then callback", async () => { + const mockCallback = vi.fn(() => ({ serverBundle })); + const adapter = buildAdapter(mockCallback); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(compileOpenNextConfig).toHaveBeenCalledWith("open-next.config.ts", { compileEdge: true }); + expect(mockCallback).toHaveBeenCalledOnce(); + // The callback receives (config, buildOpts) + expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({ default: {} }), expect.any(Object)); + }); + + test("modifyConfig returns nextConfig with cacheHandler, cacheHandlers, cacheMaxMemorySize, and trustHostHeader", async () => { + const adapter = buildAdapter(() => ({ serverBundle })); + + const nextConfig = { + experimental: { serverActions: true }, + images: {}, + } as unknown as BuildCompleteContext["config"]; + + const result = await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(result.cacheHandler).toBe("/tmp/cache.cjs"); + expect(result.cacheHandlers).toEqual({ + default: "/tmp/composable-cache.cjs", + remote: "/tmp/composable-cache.cjs", + }); + expect(result.cacheMaxMemorySize).toBe(0); + expect(result.experimental.trustHostHeader).toBe(true); + // Original experimental properties preserved + expect(result.experimental.serverActions).toBe(true); + }); + + test("onBuildComplete calls createMiddleware with influence.middlewareOptions", async () => { + const adapter = buildAdapter(() => ({ + serverBundle, + middlewareOptions: { forceOnlyBuildOnce: true }, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createMiddleware).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ forceOnlyBuildOnce: true }) + ); + }); + + test("onBuildComplete skips createRevalidationBundle when skipRevalidation is true", async () => { + const adapter = buildAdapter(() => ({ + serverBundle, + skipRevalidation: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createRevalidationBundle).not.toHaveBeenCalled(); + }); + + test("onBuildComplete calls influence.beforeServerBundle BEFORE createMiddleware", async () => { + const callOrder: string[] = []; + + const adapter = buildAdapter(() => ({ + serverBundle, + beforeServerBundle: vi.fn(async () => { + callOrder.push("beforeServerBundle"); + }), + })); + + vi.mocked(createMiddleware).mockImplementation(async () => { + callOrder.push("createMiddleware"); + }); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(callOrder).toEqual(["beforeServerBundle", "createMiddleware"]); + }); + + test("onBuildComplete calls influence.afterServerBundle after createServerBundle but BEFORE createRevalidationBundle", async () => { + const callOrder: string[] = []; + + const adapter = buildAdapter(() => ({ + serverBundle, + afterServerBundle: vi.fn(async () => { + callOrder.push("afterServerBundle"); + }), + })); + + vi.mocked(createServerBundle).mockImplementation(async () => { + callOrder.push("createServerBundle"); + }); + + vi.mocked(createRevalidationBundle).mockImplementation(async () => { + callOrder.push("createRevalidationBundle"); + }); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(callOrder).toEqual(["createServerBundle", "afterServerBundle", "createRevalidationBundle"]); + }); + + test("edge compilation failure retries with compileEdge: false and logs a warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + vi.mocked(compileOpenNextConfig) + .mockRejectedValueOnce(new Error("Edge compilation failed: cannot resolve node:fs")) + .mockResolvedValueOnce({ + config: { default: {}, dangerous: {} } as unknown as OpenNextConfig, + buildDir: "/tmp/open-next-tmp", + }); + + const adapter = buildAdapter(() => ({ serverBundle })); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(compileOpenNextConfig).toHaveBeenCalledTimes(2); + expect(compileOpenNextConfig).toHaveBeenNthCalledWith(1, "open-next.config.ts", { compileEdge: true }); + expect(compileOpenNextConfig).toHaveBeenNthCalledWith(2, "open-next.config.ts", { compileEdge: false }); + expect(warnSpy).toHaveBeenCalledOnce(); + + warnSpy.mockRestore(); + }); + + test("influence.tempCachePath override is called with (buildOpts, packagePath)", async () => { + const mockTempCachePath = vi.fn(() => "/custom/temp/cache/path"); + + const adapter = buildAdapter(() => ({ + serverBundle, + tempCachePath: mockTempCachePath, + })); + + vi.mocked(buildHelper.getPackagePath).mockReturnValue("packages/my-app"); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(mockTempCachePath).toHaveBeenCalledWith(expect.any(Object), "packages/my-app"); + + // Verify the custom path was used for mkdirSync + const fs = await import("node:fs"); + expect(fs.default.mkdirSync).toHaveBeenCalledWith("/custom/temp/cache/path", { recursive: true }); + }); + + test("onBuildComplete skips image optimization when skipImageOptimization is true", async () => { + const adapter = buildAdapter(() => ({ + serverBundle, + skipImageOptimization: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createImageOptimizationBundle).not.toHaveBeenCalled(); + }); + + test("onBuildComplete skips warmer when skipWarmer is true", async () => { + const adapter = buildAdapter(() => ({ + serverBundle, + skipWarmer: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createWarmerBundle).not.toHaveBeenCalled(); + }); + + test("onBuildComplete skips generateOutput when skipGenerateOutput is true", async () => { + const adapter = buildAdapter(() => ({ + serverBundle, + skipGenerateOutput: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(buildOpenNextOutput).not.toHaveBeenCalled(); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).not.toHaveBeenCalled(); + }); + + test("onBuildComplete calls addDebugFile with outputs.json", async () => { + const adapter = buildAdapter(() => ({ serverBundle })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(addDebugFile).toHaveBeenCalledWith(expect.any(Object), "outputs.json", ctx); + }); + + test("onBuildComplete passes serverBundle customization to createServerBundle", async () => { + const mockPlugins = vi.fn(() => []); + const mockPatches = [{ name: "test-patch", patches: [] }]; + + const adapter = buildAdapter(() => ({ + serverBundle: { + additionalPlugins: mockPlugins, + additionalCodePatches: mockPatches, + useEdgeConfig: true, + externals: ["some-external"], + banner: ["// custom banner"], + }, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createServerBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + additionalPlugins: expect.any(Function), + additionalCodePatches: mockPatches, + useEdgeConfig: true, + externals: ["some-external"], + banner: ["// custom banner"], + }), + ctx.outputs + ); + }); + + test("onBuildComplete compiles tag cache provider when shouldUseTagCache is true", async () => { + vi.mocked(createCacheAssets).mockReturnValue({ + shouldUseTagCache: true, + metaFiles: [], + }); + + const adapter = buildAdapter(() => ({ serverBundle })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(compileTagCacheProvider).toHaveBeenCalledWith(expect.any(Object), undefined); + }); + + test("onBuildComplete skips cache assets when disableIncrementalCache is true", async () => { + const mockBuildOpts = createMockBuildOpts(); + (mockBuildOpts.config as OpenNextConfig).dangerous = { disableIncrementalCache: true }; + vi.mocked(buildHelper.normalizeOptions).mockReturnValue(mockBuildOpts); + + const adapter = buildAdapter(() => ({ serverBundle })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createCacheAssets).not.toHaveBeenCalled(); + expect(compileTagCacheProvider).not.toHaveBeenCalled(); + }); + + test("validateConfig override is called after callback in modifyConfig and halts build on shouldThrow:true", async () => { + const mockValidator = vi.fn(() => ({ success: false, shouldThrow: true, message: "nope" })); + const adapter = buildAdapter(() => ({ serverBundle, validateConfig: mockValidator })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await expect(adapter.modifyConfig(nextConfig, { phase: "production" })).rejects.toThrow("nope"); + expect(mockValidator).toHaveBeenCalledOnce(); + }); + + test("validateConfig override with shouldThrow:false logs warn and continues", async () => { + const mockValidator = vi.fn(() => ({ success: false, message: "heads up" })); + const adapter = buildAdapter(() => ({ serverBundle, validateConfig: mockValidator })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + expect(logger.warn).toHaveBeenCalledWith("heads up"); + }); + + test("onBuildComplete calls generateOutput override and writes its return via buildAdapter", async () => { + const mockOutput = vi.fn(async () => ({ custom: "shape" })); + const adapter = buildAdapter(() => ({ serverBundle, generateOutput: mockOutput })); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + expect(mockOutput).toHaveBeenCalledWith(expect.any(Object)); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(/\/\.open-next\/open-next\.output\.json$/), + JSON.stringify({ custom: "shape" }) + ); + }); + + test("onBuildComplete with default generateOutput calls buildOpenNextOutput and writes result", async () => { + vi.mocked(buildOpenNextOutput).mockResolvedValue({ + origins: { default: {} }, + } as any); // oxlint-disable-line @typescript-eslint/no-explicit-any + const adapter = buildAdapter(() => ({ serverBundle })); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object)); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(/\/\.open-next\/open-next\.output\.json$/), + JSON.stringify({ origins: { default: {} } }) + ); + }); + + test("onBuildComplete skipGenerateOutput skips generateOutput override and buildOpenNextOutput", async () => { + const mockOutput = vi.fn(); + const adapter = buildAdapter(() => ({ + serverBundle, + skipGenerateOutput: true, + generateOutput: mockOutput, + })); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + expect(buildOpenNextOutput).not.toHaveBeenCalled(); + expect(mockOutput).not.toHaveBeenCalled(); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts new file mode 100644 index 00000000..da10aca6 --- /dev/null +++ b/packages/core/src/build/adapter.ts @@ -0,0 +1,275 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import type { Plugin } from "esbuild"; + +import { addDebugFile } from "../debug.js"; +import logger from "../logger.js"; +import type { ContentUpdater } from "../plugins/content-updater.js"; +import type { BundleDefaults } from "../plugins/resolve.js"; +import type { NextAdapterOutputs } from "../types/adapter.js"; +import type { NextConfig } from "../types/next-types.js"; +import type { OpenNextConfig } from "../types/open-next.js"; + +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"; +import { createServerBundle } from "./createServerBundle.js"; +import { createWarmerBundle } from "./createWarmerBundle.js"; +import { buildOpenNextOutput } from "./generateOutput.js"; +import type { OpenNextOutput } from "./generateOutput.js"; +import * as buildHelper from "./helper.js"; +import type { CodePatcher } from "./patch/codePatcher.js"; +import type { ValidateConfigResult } from "./validateConfig.js"; + +const require = createRequire(import.meta.url); + +/** + * The parameter type for onBuildComplete. + */ +export type BuildCompleteContext = { + routes: unknown; + outputs: NextAdapterOutputs; + projectDir: string; + repoRoot: string; + distDir: string; + config: NextConfig; + nextVersion: string; +}; + +/** + * The return type of buildAdapter — the adapter interface that Next.js consumes. + */ +export type NextAdapter = { + name: string; + modifyConfig: (config: NextConfig, { phase }: { phase: string }) => Promise; + onBuildComplete: (props: BuildCompleteContext) => Promise; +}; + +/** + * The influence an adapter can exert on the build process, returned by the callback. + */ +export type OpenNextAdapterOptions = { + skipRevalidation?: boolean; + skipImageOptimization?: boolean; + skipWarmer?: boolean; + skipCache?: boolean; + skipGenerateOutput?: boolean; + middlewareOptions?: { forceOnlyBuildOnce?: boolean }; + serverBundle: { + additionalPlugins?: (updater: ContentUpdater, outputs: NextAdapterOutputs) => Plugin[]; + additionalCodePatches?: CodePatcher[]; + useEdgeConfig?: boolean; + /** + * The esbuild externals for the server bundle. Every adapter has to declare + * them explicitly — there is no implicit default. + */ + externals: string[]; + banner?: string[] | ((name: string) => string[]); + }; + beforeServerBundle?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; + afterServerBundle?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; + tempCachePath?: (buildOpts: buildHelper.BuildOptions, packagePath: string) => string; + /** + * Bundle-specific default override names applied when the user's + * open-next.config.ts does not specify an override for a given key. + * Each bundle type (server, middleware, edge, imageOptimization, + * revalidation, warmer, tagCache) can have its own separate defaults map. + * Precedence: config override > platform default > core node default. + */ + defaultOverrides?: BundleDefaults; + validateConfig?: (config: OpenNextConfig) => ValidateConfigResult | Promise; + generateOutput?: (buildOpts: buildHelper.BuildOptions) => Promise; +}; + +/** + * Creates a NextAdapter that orchestrates the OpenNext build pipeline. + * + * This function eliminates duplicated build logic across platform-specific adapters + * (AWS, Cloudflare, etc.) by centralizing the build orchestration in core. + * + * @param callback - A function that receives the OpenNext config and build options, + * returning adapter-specific influence over the build process. + * @returns A NextAdapter with modifyConfig and onBuildComplete hooks. + */ +export function buildAdapter( + callback: (config: OpenNextConfig, buildOpts: buildHelper.BuildOptions) => OpenNextAdapterOptions +): NextAdapter { + // Closure-scoped state — no module-level mutable variables + let buildOpts: buildHelper.BuildOptions; + let config: OpenNextConfig; + let adapterOptions: OpenNextAdapterOptions; + + return { + name: "OpenNext", + + async modifyConfig(nextConfig, { phase: _phase }) { + // Step 1: Compile OpenNext config with edge support, fallback on failure + let result: { config: OpenNextConfig; buildDir: string }; + try { + result = await compileOpenNextConfig("open-next.config.ts", { compileEdge: true }); + } catch (error) { + console.warn( + "Failed to compile open-next.config.ts for edge runtime, falling back to node-only compilation.", + error instanceof Error ? error.message : error + ); + result = await compileOpenNextConfig("open-next.config.ts", { compileEdge: false }); + } + + config = result.config; + const buildDir = result.buildDir; + + // Step 2: Resolve openNextDistDir + const openNextDistDir = path.dirname(require.resolve("@opennextjs/core/debug.js")); + + // Step 3: Normalize options + buildOpts = buildHelper.normalizeOptions(config, openNextDistDir, buildDir); + + // Step 4: Initialize output directory + buildHelper.initOutputDir(buildOpts); + + // Step 5: Compile cache + const cache = compileCache(buildOpts); + + // Step 6: Call the adapter callback to get influence + adapterOptions = callback(config, buildOpts); + + // Run adapter-level validate override (additional check; default already ran in compileOpenNextConfig) + if (adapterOptions.validateConfig) { + const result = await adapterOptions.validateConfig(config); + if (!result.success) { + if (result.shouldThrow) { + throw new Error(result.message); + } + const level = result.level ?? "warn"; + logger[level](result.message); + } + } + + // Step 7: Build tempCachePath + const packagePath = buildHelper.getPackagePath(buildOpts); + const tempCachePath = + adapterOptions.tempCachePath?.(buildOpts, packagePath) ?? + path.join(buildOpts.outputDir, "server-functions/default", packagePath, ".open-next/.build"); + + // Step 8: Copy cache files + fs.mkdirSync(tempCachePath, { recursive: true }); + fs.copyFileSync(cache.cache, path.join(tempCachePath, "cache.cjs")); + fs.copyFileSync(cache.composableCache, path.join(tempCachePath, "composable-cache.cjs")); + + // Step 10: Return modified nextConfig + return { + ...nextConfig, + cacheHandler: cache.cache, + cacheHandlers: { + default: cache.composableCache, + remote: cache.composableCache, + }, + cacheMaxMemorySize: 0, + experimental: { + ...nextConfig.experimental, + trustHostHeader: true, + }, + }; + }, + + async onBuildComplete(ctx) { + logger.info("OpenNext build will start now"); + + // Step 1: Save debug output + addDebugFile(buildOpts, "outputs.json", ctx); + + // Step 2: Call beforeServerBundle hook + await adapterOptions.beforeServerBundle?.(buildOpts, config); + + const bundleDefaults = adapterOptions.defaultOverrides; + + // Step 3: Create middleware + await createMiddleware(buildOpts, { + ...adapterOptions.middlewareOptions, + defaultOverrides: bundleDefaults?.middleware, + }); + console.log("Middleware created"); + + // Step 4: Create static assets + createStaticAssets(buildOpts); + console.log("Static assets created"); + + // Step 5: Cache assets + if (buildOpts.config.dangerous?.disableIncrementalCache !== true) { + const { shouldUseTagCache } = createCacheAssets(buildOpts); + console.log("Cache assets created"); + if (shouldUseTagCache) { + await compileTagCacheProvider(buildOpts, bundleDefaults?.tagCache); + console.log("Tag cache provider compiled"); + } + } + + // Step 6: Build wrapped additionalPlugins + const serverBundle = adapterOptions.serverBundle; + const wrappedAdditionalPlugins = serverBundle.additionalPlugins + ? (updater: ContentUpdater) => serverBundle.additionalPlugins!(updater, ctx.outputs) + : undefined; + + // Step 7: Create server bundle + await createServerBundle( + buildOpts, + { + additionalPlugins: wrappedAdditionalPlugins, + additionalCodePatches: serverBundle.additionalCodePatches, + useEdgeConfig: serverBundle.useEdgeConfig, + externals: serverBundle.externals, + banner: serverBundle.banner, + bundleDefaults, + }, + ctx.outputs + ); + console.log("Server bundle created"); + + // Step 8: Call afterServerBundle hook + await adapterOptions.afterServerBundle?.(buildOpts, config); + + // Step 9: Revalidation bundle + if (!adapterOptions.skipRevalidation) { + await createRevalidationBundle(buildOpts, bundleDefaults?.revalidation); + console.log("Revalidation bundle created"); + } + + // Step 10: Image optimization bundle + if (!adapterOptions.skipImageOptimization) { + await createImageOptimizationBundle(buildOpts, bundleDefaults?.imageOptimization); + console.log("Image optimization bundle created"); + } + + // Step 11: Cache bundle + if (!adapterOptions.skipCache && config.dangerous?.disableIncrementalCache !== true) { + await createCacheBundle(buildOpts, bundleDefaults?.cache); + console.log("Cache bundle created"); + } + + // Step 12: Warmer bundle + if (!adapterOptions.skipWarmer) { + await createWarmerBundle(buildOpts, bundleDefaults?.warmer); + console.log("Warmer bundle created"); + } + + // Step 13: Generate output + if (!adapterOptions.skipGenerateOutput) { + const output = adapterOptions.generateOutput + ? await adapterOptions.generateOutput(buildOpts) + : await buildOpenNextOutput(buildOpts); + fs.writeFileSync( + path.join(buildOpts.appBuildOutputPath, ".open-next", "open-next.output.json"), + JSON.stringify(output) + ); + console.log("Output generated"); + } + }, + }; +} diff --git a/packages/core/src/build/compileConfig.ts b/packages/core/src/build/compileConfig.ts index d4da6813..fe87932a 100644 --- a/packages/core/src/build/compileConfig.ts +++ b/packages/core/src/build/compileConfig.ts @@ -38,7 +38,14 @@ export async function compileOpenNextConfig( process.exit(1); } - validateConfig(config); + const validateResult = validateConfig(config); + if (!validateResult.success) { + if (validateResult.shouldThrow) { + throw new Error(validateResult.message); + } + const level = validateResult.level ?? "warn"; + logger[level](validateResult.message); + } // We need to check if the config uses the edge runtime at any point // If it does, we need to compile it with the edge runtime diff --git a/packages/core/src/build/compileTagCacheProvider.ts b/packages/core/src/build/compileTagCacheProvider.ts index 4cddb783..5bda559b 100644 --- a/packages/core/src/build/compileTagCacheProvider.ts +++ b/packages/core/src/build/compileTagCacheProvider.ts @@ -1,11 +1,15 @@ import path from "node:path"; +import type { DefaultOverrides } from "../plugins/resolve.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; -export async function compileTagCacheProvider(options: buildHelper.BuildOptions) { +export async function compileTagCacheProvider( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { const providerPath = path.join(options.outputDir, "dynamodb-provider"); const overrides = options.config.initializationFunction?.override; @@ -20,10 +24,15 @@ export async function compileTagCacheProvider(options: buildHelper.BuildOptions) openNextResolvePlugin({ fnName: "initializationFunction", overrides: { - converter: overrides?.converter ?? "dummy", + converter: overrides?.converter, wrapper: overrides?.wrapper, tagCache: options.config.initializationFunction?.tagCache, }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "dummy", + wrapper: defaultOverrides?.wrapper, + tagCache: defaultOverrides?.tagCache, + }, }), ], }, diff --git a/packages/core/src/build/createAssets.ts b/packages/core/src/build/createAssets.ts index 4ad81bbd..5a369894 100644 --- a/packages/core/src/build/createAssets.ts +++ b/packages/core/src/build/createAssets.ts @@ -86,7 +86,7 @@ export function createCacheAssets(options: buildHelper.BuildOptions) { const { appBuildOutputPath, outputDir } = options; const packagePath = buildHelper.getPackagePath(options); const buildId = buildHelper.getBuildId(options); - let useTagCache = false; + let shouldUseTagCache = false; const dotNextPath = appBuildOutputPath; @@ -258,7 +258,7 @@ export function createCacheAssets(options: buildHelper.BuildOptions) { }); if (metaFiles.length > 0) { - useTagCache = true; + shouldUseTagCache = true; const providerPath = path.join(outputDir, "dynamodb-provider"); // Copy open-next.config.mjs into the bundle @@ -270,5 +270,5 @@ export function createCacheAssets(options: buildHelper.BuildOptions) { } } - return { useTagCache, metaFiles }; + return { shouldUseTagCache, metaFiles }; } diff --git a/packages/core/src/build/createCacheBundle.ts b/packages/core/src/build/createCacheBundle.ts new file mode 100644 index 00000000..e61b9b4f --- /dev/null +++ b/packages/core/src/build/createCacheBundle.ts @@ -0,0 +1,53 @@ +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"; + +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 Lambda code + await buildHelper.esbuildAsync( + { + external: ["next"], + entryPoints: [path.join(options.openNextDistDir, "adapters", "cache-adapter.js")], + outfile: path.join(outputPath, "index.mjs"), + plugins: [ + openNextResolvePlugin({ + fnName: "cache", + overrides: { + converter: config.cacheHandler?.override?.converter, + wrapper: config.cacheHandler?.override?.wrapper, + incrementalCache: config.cacheHandler?.incrementalCache, + tagCache: config.cacheHandler?.tagCache, + cdnInvalidation: config.cacheHandler?.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/createImageOptimizationBundle.ts b/packages/core/src/build/createImageOptimizationBundle.ts index 73e08c86..c33339d5 100644 --- a/packages/core/src/build/createImageOptimizationBundle.ts +++ b/packages/core/src/build/createImageOptimizationBundle.ts @@ -3,12 +3,16 @@ import os from "node:os"; 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"; import { installDependencies } from "./installDeps.js"; -export async function createImageOptimizationBundle(options: buildHelper.BuildOptions) { +export async function createImageOptimizationBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { logger.info("Bundling image optimization function..."); const { appBuildOutputPath, config, outputDir } = options; @@ -28,6 +32,11 @@ export async function createImageOptimizationBundle(options: buildHelper.BuildOp wrapper: config.imageOptimization?.override?.wrapper, imageLoader: config.imageOptimization?.loader, }, + defaultOverrides: { + converter: defaultOverrides?.converter, + wrapper: defaultOverrides?.wrapper, + imageLoader: defaultOverrides?.imageLoader, + }, }), ]; diff --git a/packages/core/src/build/createMiddleware.ts b/packages/core/src/build/createMiddleware.ts index 6c1de5e8..963a3568 100644 --- a/packages/core/src/build/createMiddleware.ts +++ b/packages/core/src/build/createMiddleware.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { loadFunctionsConfigManifest, loadMiddlewareManifest } from "@/config/util.js"; import logger from "../logger.js"; +import type { DefaultOverrides } from "../plugins/resolve.js"; import type { MiddlewareInfo } from "../types/next-types.js"; import { buildEdgeBundle, copyMiddlewareResources } from "./edge/createEdgeBundle.js"; @@ -19,7 +20,10 @@ import { buildBundledNodeMiddleware, buildExternalNodeMiddleware } from "./middl */ export async function createMiddleware( options: buildHelper.BuildOptions, - { forceOnlyBuildOnce = false } = {} + { + forceOnlyBuildOnce = false, + defaultOverrides, + }: { forceOnlyBuildOnce?: boolean; defaultOverrides?: DefaultOverrides } = {} ) { logger.info("Bundling middleware function..."); @@ -37,7 +41,7 @@ export async function createMiddleware( if (functionsConfigManifest?.functions["/_middleware"]) { await (config.middleware?.external - ? buildExternalNodeMiddleware(options) + ? buildExternalNodeMiddleware(options, defaultOverrides) : buildBundledNodeMiddleware(options)); return; } @@ -66,10 +70,11 @@ export async function createMiddleware( ...config.middleware.override, originResolver: config.middleware.originResolver, }, - defaultConverter: "aws-cloudfront", + defaultConverter: "@opennextjs/core/overrides/converters/edge.js", additionalExternals: config.edgeExternals, onlyBuildOnce: forceOnlyBuildOnce === true, name: "middleware", + defaultOverrides, }); installDependencies(outputPath, config.middleware?.install); @@ -82,6 +87,7 @@ export async function createMiddleware( overrides: config.default.override, onlyBuildOnce: true, name: "middleware", + defaultOverrides, }); } } diff --git a/packages/core/src/build/createRevalidationBundle.ts b/packages/core/src/build/createRevalidationBundle.ts index fe8b50b2..f6dc5d0d 100644 --- a/packages/core/src/build/createRevalidationBundle.ts +++ b/packages/core/src/build/createRevalidationBundle.ts @@ -2,12 +2,16 @@ 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"; import { installDependencies } from "./installDeps.js"; -export async function createRevalidationBundle(options: buildHelper.BuildOptions) { +export async function createRevalidationBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { logger.info("Bundling revalidation function..."); const { appBuildOutputPath, config, outputDir } = options; @@ -29,9 +33,13 @@ export async function createRevalidationBundle(options: buildHelper.BuildOptions openNextResolvePlugin({ fnName: "revalidate", overrides: { - converter: config.revalidate?.override?.converter ?? "node", + converter: config.revalidate?.override?.converter, wrapper: config.revalidate?.override?.wrapper, }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "node", + wrapper: defaultOverrides?.wrapper, + }, }), ], }, diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index a4e7dd37..a68f3da9 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -11,6 +11,7 @@ import logger from "../logger.js"; import { minifyAll } from "../minimize-js.js"; import { ContentUpdater } from "../plugins/content-updater.js"; import { openNextReplacementPlugin } from "../plugins/replacement.js"; +import type { BundleDefaults } from "../plugins/resolve.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import { getCrossPlatformPathRegex } from "../utils/regex.js"; @@ -29,11 +30,17 @@ interface CodeCustomization { // These plugins are meant to apply during the esbuild bundling process. // This will only apply to OpenNext code. additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[]; + useEdgeConfig?: boolean; + // The esbuild externals for the server bundle. Mandatory: every adapter has to + // declare what it keeps external instead of relying on an implicit default. + externals: string[]; + banner?: string[] | ((name: string) => string[]); + bundleDefaults?: BundleDefaults; } export async function createServerBundle( options: buildHelper.BuildOptions, - codeCustomization?: CodeCustomization, + codeCustomization: CodeCustomization, nextOutputs?: NextAdapterOutputs ) { const { config } = options; @@ -51,7 +58,7 @@ export async function createServerBundle( const routes = fnOptions.routes; routes.forEach((route) => foundRoutes.add(route)); if (fnOptions.runtime === "edge") { - await generateEdgeBundle(name, options, fnOptions); + await generateEdgeBundle(name, options, fnOptions, undefined, codeCustomization.bundleDefaults?.edge); } else { await generateBundle(name, options, fnOptions, codeCustomization, nextOutputs); } @@ -123,7 +130,7 @@ async function generateBundle( name: string, options: buildHelper.BuildOptions, fnOptions: SplittedFunctionOptions, - codeCustomization?: CodeCustomization, + codeCustomization: CodeCustomization, nextOutputs?: NextAdapterOutputs ) { const { appPath, appBuildOutputPath, config, outputDir, monorepoRoot } = options; @@ -168,7 +175,7 @@ async function generateBundle( } // Copy open-next.config.mjs - buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath); + buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath, codeCustomization.useEdgeConfig ?? false); // Copy env files buildHelper.copyEnvFile(appBuildOutputPath, packagePath, outputPath); @@ -186,7 +193,7 @@ async function generateBundle( tracedFiles = await copyAdapterFiles(options, name, packagePath, nextOutputs); //TODO: we should load manifests here - const additionalCodePatches = codeCustomization?.additionalCodePatches ?? []; + const additionalCodePatches = codeCustomization.additionalCodePatches ?? []; await applyCodePatches(options, tracedFiles, manifests as ReturnType, [ patches.patchFetchCacheSetMissingWaitUntil, @@ -206,12 +213,13 @@ async function generateBundle( // Next.js app. const overrides = fnOptions.override ?? {}; + const defaultOverrides = codeCustomization.bundleDefaults?.server; const disableRouting = config.middleware?.external; const updater = new ContentUpdater(options); - const additionalPlugins = codeCustomization?.additionalPlugins + const additionalPlugins = codeCustomization.additionalPlugins ? codeCustomization.additionalPlugins(updater) : []; @@ -225,6 +233,7 @@ async function generateBundle( openNextResolvePlugin({ fnName: name, overrides, + defaultOverrides, }), ...additionalPlugins, // The content updater plugin must be the last plugin @@ -232,23 +241,29 @@ async function generateBundle( ]; const outfileExt = fnOptions.runtime === "deno" ? "ts" : "mjs"; + const defaultBanner = [ + `globalThis.monorepoPackagePath = "${packagePath}";`, + "import process from 'node:process';", + "import { Buffer } from 'node:buffer';", + "import { createRequire as topLevelCreateRequire } from 'module';", + "const require = topLevelCreateRequire(import.meta.url);", + "import bannerUrl from 'url';", + "const __dirname = bannerUrl.fileURLToPath(new URL('.', import.meta.url));", + "const __filename = bannerUrl.fileURLToPath(import.meta.url);", + name === "default" ? "" : `globalThis.fnName = "${name}";`, + ]; + const bannerLines = + typeof codeCustomization.banner === "function" + ? codeCustomization.banner(name) + : (codeCustomization.banner ?? defaultBanner); + await buildHelper.esbuildAsync( { entryPoints: [path.join(options.openNextDistDir, "adapters", "server-adapter.js")], - external: ["next", "./middleware.mjs", "./next-server.runtime.prod.js"], + external: codeCustomization.externals, outfile: path.join(outputPath, packagePath, `index.${outfileExt}`), banner: { - js: [ - `globalThis.monorepoPackagePath = "${packagePath}";`, - "import process from 'node:process';", - "import { Buffer } from 'node:buffer';", - "import { createRequire as topLevelCreateRequire } from 'module';", - "const require = topLevelCreateRequire(import.meta.url);", - "import bannerUrl from 'url';", - "const __dirname = bannerUrl.fileURLToPath(new URL('.', import.meta.url));", - "const __filename = bannerUrl.fileURLToPath(import.meta.url);", - name === "default" ? "" : `globalThis.fnName = "${name}";`, - ].join(""), + js: bannerLines.join(""), }, plugins, }, diff --git a/packages/core/src/build/createWarmerBundle.ts b/packages/core/src/build/createWarmerBundle.ts index a0ed877a..f8ee943f 100644 --- a/packages/core/src/build/createWarmerBundle.ts +++ b/packages/core/src/build/createWarmerBundle.ts @@ -2,12 +2,16 @@ 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"; import { installDependencies } from "./installDeps.js"; -export async function createWarmerBundle(options: buildHelper.BuildOptions) { +export async function createWarmerBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { logger.info("Bundling warmer function..."); const { config, outputDir } = options; @@ -31,9 +35,13 @@ export async function createWarmerBundle(options: buildHelper.BuildOptions) { plugins: [ openNextResolvePlugin({ overrides: { - converter: config.warmer?.override?.converter ?? "dummy", + converter: config.warmer?.override?.converter, wrapper: config.warmer?.override?.wrapper, }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "dummy", + wrapper: defaultOverrides?.wrapper, + }, fnName: "warmer", }), ], diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index 5d7370aa..cbeef42f 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -6,7 +6,6 @@ import { type Plugin, build } from "esbuild"; import { loadMiddlewareManifest } from "@/config/util.js"; import type { MiddlewareInfo } from "@/types/next-types"; import type { - IncludedConverter, IncludedOriginResolver, LazyLoadedOverride, OverrideOptions, @@ -20,6 +19,7 @@ import { ContentUpdater } from "../../plugins/content-updater.js"; import { openNextEdgePlugins } from "../../plugins/edge.js"; import { openNextExternalMiddlewarePlugin } from "../../plugins/externalMiddleware.js"; import { openNextReplacementPlugin } from "../../plugins/replacement.js"; +import type { DefaultOverrides } from "../../plugins/resolve.js"; import { openNextResolvePlugin } from "../../plugins/resolve.js"; import { getCrossPlatformPathRegex } from "../../utils/regex.js"; import { type BuildOptions, isEdgeRuntime, copyOpenNextConfig, esbuildAsync } from "../helper.js"; @@ -33,12 +33,13 @@ interface BuildEdgeBundleOptions { outfile: string; options: BuildOptions; overrides?: Override; - defaultConverter?: IncludedConverter; + defaultConverter?: string; additionalInject?: string; additionalExternals?: string[]; onlyBuildOnce?: boolean; name: string; additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[]; + defaultOverrides?: DefaultOverrides; } export async function buildEdgeBundle({ @@ -53,6 +54,7 @@ export async function buildEdgeBundle({ onlyBuildOnce, name, additionalPlugins: additionalPluginsFn, + defaultOverrides, }: BuildEdgeBundleOptions) { const isInCloudflare = await isEdgeRuntime(overrides); function override(target: T) { @@ -73,13 +75,23 @@ export async function buildEdgeBundle({ plugins: [ openNextResolvePlugin({ overrides: { - wrapper: override("wrapper") ?? "aws-lambda", - converter: override("converter") ?? defaultConverter, - tagCache: override("tagCache") ?? "dynamodb-lite", - incrementalCache: override("incrementalCache") ?? "s3-lite", - queue: override("queue") ?? "sqs-lite", - originResolver: override("originResolver") ?? "pattern-env", - proxyExternalRequest: override("proxyExternalRequest") ?? "node", + wrapper: override("wrapper"), + converter: override("converter"), + cache: override("cache"), + queue: override("queue"), + originResolver: override("originResolver"), + proxyExternalRequest: override("proxyExternalRequest"), + }, + defaultOverrides: { + wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/dummy.js", + converter: defaultOverrides?.converter ?? defaultConverter, + 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", + proxyExternalRequest: + defaultOverrides?.proxyExternalRequest ?? + "@opennextjs/core/overrides/proxyExternalRequest/node.js", }, fnName: name, }), @@ -167,7 +179,8 @@ export async function generateEdgeBundle( name: string, options: BuildOptions, fnOptions: SplittedFunctionOptions, - additionalPlugins: (contentUpdater: ContentUpdater) => Plugin[] = () => [] + additionalPlugins: (contentUpdater: ContentUpdater) => Plugin[] = () => [], + defaultOverrides?: DefaultOverrides ) { logger.info(`Generating edge bundle for: ${name}`); @@ -204,6 +217,7 @@ export async function generateEdgeBundle( additionalExternals: options.config.edgeExternals, name, additionalPlugins, + defaultOverrides, }); } diff --git a/packages/core/src/build/generateOutput.spec.ts b/packages/core/src/build/generateOutput.spec.ts new file mode 100644 index 00000000..0030820d --- /dev/null +++ b/packages/core/src/build/generateOutput.spec.ts @@ -0,0 +1,84 @@ +import * as fs from "node:fs"; + +import { describe, test, expect, vi } from "vitest"; + +import type { OpenNextConfig } from "../types/open-next.js"; + +import { buildOpenNextOutput, generateOutput } from "./generateOutput.js"; +import type { BuildOptions } from "./helper.js"; + +// We need to mock fs and the loadConfig import to avoid touching real files. +// The file imports { loadConfig } from "@/config/util.js" and uses fs directly. + +vi.mock("node:fs", () => ({ + default: { + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ isDirectory: () => false })), + writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), + }, + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ isDirectory: () => false })), + writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), +})); + +vi.mock("@/config/util.js", () => ({ + loadConfig: vi.fn(() => ({ basePath: "" })), +})); + +function createMockBuildOpts(): BuildOptions { + return { + appBuildOutputPath: "/app/build", + appPackageJsonPath: "/app/package.json", + appPath: "/app", + appPublicPath: "/app/public", + buildDir: "/app/.open-next/.build", + config: { + default: {}, + dangerous: {}, + } as unknown as OpenNextConfig, + debug: false, + minify: true, + monorepoRoot: "/app", + nextVersion: "16.0.0", + openNextVersion: "0.1.0", + openNextDistDir: "/fake/opennext/dist", + outputDir: "/app/.open-next", + packager: "npm" as const, + tempBuildDir: "/tmp/open-next-tmp", + }; +} + +describe("buildOpenNextOutput", () => { + test("returns an OpenNextOutput with expected keys (no fs writes)", async () => { + const opts = createMockBuildOpts(); + const output = await buildOpenNextOutput(opts); + expect(output).toHaveProperty("edgeFunctions"); + expect(output).toHaveProperty("origins"); + expect(output).toHaveProperty("behaviors"); + expect(output).toHaveProperty("additionalProps"); + // fs.writeFileSync must NOT be called by buildOpenNextOutput + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test("returns undefined revalidationFunction when disableIncrementalCache is true", async () => { + const opts = createMockBuildOpts(); + (opts.config as OpenNextConfig).dangerous = { disableIncrementalCache: true }; + const output = await buildOpenNextOutput(opts); + expect(output.additionalProps?.revalidationFunction).toBeUndefined(); + }); +}); + +describe("generateOutput (legacy wrapper)", () => { + test("calls buildOpenNextOutput then writes the file", async () => { + const opts = createMockBuildOpts(); + await generateOutput(opts); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + const [filePath, content] = vi.mocked(fs.writeFileSync).mock.calls[0] as [string, string]; + expect(filePath).toMatch(/\/\.open-next\/open-next\.output\.json$/); + const parsed = JSON.parse(content); + expect(parsed).toHaveProperty("behaviors"); + expect(parsed).toHaveProperty("origins"); + }); +}); diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index 79716cdc..19248ed3 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -66,7 +66,7 @@ type DefaultOrigins = { imageOptimizer: ImageOrigins; }; -interface OpenNextOutput { +export interface OpenNextOutput { edgeFunctions: { [key: string]: BaseFunction; } & { @@ -86,6 +86,7 @@ interface OpenNextOutput { initializationFunction?: BaseFunction; warmer?: BaseFunction; revalidationFunction?: BaseFunction; + cacheFunction?: BaseFunction; }; } @@ -102,6 +103,20 @@ async function canStream(opts: FunctionOptions) { return wrapper.supportStreaming; } +/** + * Extracts the bare name from a full-path override string. + * Full paths like "@opennextjs/aws/overrides/wrappers/aws-lambda.js" → "aws-lambda". + * Bare names like "edge" pass through unchanged. + */ +function bare(s: string): string { + if (s.startsWith("@") || s.includes("/")) { + const lastSlash = s.lastIndexOf("/"); + const filename = lastSlash >= 0 ? s.slice(lastSlash + 1) : s; + return filename.replace(/\.js$/, ""); + } + return s; +} + async function extractOverrideName( defaultName: string, override?: LazyLoadedOverride | string @@ -110,7 +125,7 @@ async function extractOverrideName( return defaultName; } if (typeof override === "string") { - return override; + return bare(override); } const overrideModule = await override(); return overrideModule.name; @@ -128,18 +143,20 @@ async function extractOverrideFn(override?: DefaultOverrideOptions) { return { wrapper, converter }; } +//TODO: fix this, this is stupid async function extractCommonOverride(override?: OverrideOptions) { if (!override) { return { queue: "sqs", - incrementalCache: "s3", - tagCache: "dynamodb", + incrementalCache: "s3" as const, + tagCache: "dynamodb" as const, }; } 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 }; + // incrementalCache and tagCache are no longer in OverrideOptions — they use defaults. + // When using a composite cache (default), composite.ts wraps s3 + dynamodb internally. + // Custom implementations should be provided via the cacheHandler config or a custom cache override. + return { queue, incrementalCache: "s3" as const, tagCache: "dynamodb" as const }; } function prefixPattern(basePath: string) { @@ -149,7 +166,7 @@ function prefixPattern(basePath: string) { }; } -export async function generateOutput(options: BuildOptions) { +export async function buildOpenNextOutput(options: BuildOptions): Promise { const { appBuildOutputPath, config } = options; const edgeFunctions: OpenNextOutput["edgeFunctions"] = {}; const isExternalMiddleware = config.middleware?.external ?? false; @@ -331,10 +348,21 @@ export async function generateOutput(options: BuildOptions) { handler: indexHandler, bundle: ".open-next/revalidation-function", }, + cacheFunction: config.dangerous?.disableIncrementalCache + ? undefined + : { + handler: indexHandler, + bundle: ".open-next/cache-function", + }, }, }; + return output; +} + +export async function generateOutput(options: BuildOptions) { + const output = await buildOpenNextOutput(options); fs.writeFileSync( - path.join(appBuildOutputPath, ".open-next", "open-next.output.json"), + path.join(options.appBuildOutputPath, ".open-next", "open-next.output.json"), JSON.stringify(output) ); } diff --git a/packages/core/src/build/helper.ts b/packages/core/src/build/helper.ts index 159495b9..766ab69d 100644 --- a/packages/core/src/build/helper.ts +++ b/packages/core/src/build/helper.ts @@ -117,6 +117,7 @@ export function esbuildSync(esbuildOptions: ESBuildOptions, options: BuildOption esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); @@ -150,6 +151,7 @@ export async function esbuildAsync(esbuildOptions: ESBuildOptions, options: Buil esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 8b4a2c74..81d49cc0 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -7,6 +7,7 @@ import { getCrossPlatformPathRegex } from "@/utils/regex.js"; import { openNextExternalMiddlewarePlugin } from "../../plugins/externalMiddleware.js"; import { openNextReplacementPlugin } from "../../plugins/replacement.js"; +import type { DefaultOverrides } from "../../plugins/resolve.js"; import { openNextResolvePlugin } from "../../plugins/resolve.js"; import { copyTracedFiles } from "../copyTracedFiles.js"; import * as buildHelper from "../helper.js"; @@ -16,7 +17,10 @@ type Override = OverrideOptions & { originResolver?: LazyLoadedOverride | IncludedOriginResolver; }; -export async function buildExternalNodeMiddleware(options: buildHelper.BuildOptions) { +export async function buildExternalNodeMiddleware( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { const { appBuildOutputPath, config, outputDir } = options; if (!config.middleware?.external) { throw new Error("This function should only be called for external middleware"); @@ -59,13 +63,23 @@ export async function buildExternalNodeMiddleware(options: buildHelper.BuildOpti plugins: [ openNextResolvePlugin({ overrides: { - wrapper: override("wrapper") ?? "aws-lambda", - converter: override("converter") ?? "aws-cloudfront", - tagCache: override("tagCache") ?? "dynamodb-lite", - incrementalCache: override("incrementalCache") ?? "s3-lite", - queue: override("queue") ?? "sqs-lite", - originResolver: override("originResolver") ?? "pattern-env", - proxyExternalRequest: override("proxyExternalRequest") ?? "node", + wrapper: override("wrapper"), + converter: override("converter"), + cache: override("cache"), + queue: override("queue"), + originResolver: override("originResolver"), + proxyExternalRequest: override("proxyExternalRequest"), + }, + defaultOverrides: { + wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/node.js", + converter: defaultOverrides?.converter ?? "@opennextjs/core/overrides/converters/node.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", + proxyExternalRequest: + defaultOverrides?.proxyExternalRequest ?? + "@opennextjs/core/overrides/proxyExternalRequest/node.js", }, fnName: "middleware", }), diff --git a/packages/core/src/build/validateConfig.spec.ts b/packages/core/src/build/validateConfig.spec.ts new file mode 100644 index 00000000..7a886e64 --- /dev/null +++ b/packages/core/src/build/validateConfig.spec.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from "vitest"; + +import type { OpenNextConfig } from "../types/open-next.js"; + +import { validateConfig } from "./validateConfig.js"; + +describe("validateConfig", () => { + test("returns success for minimal valid config", () => { + const result = validateConfig({ default: {} } as OpenNextConfig); + expect(result.success).toBe(true); + }); + + test("returns shouldThrow:true for splitted function with no routes", () => { + const config = { + default: {}, + functions: { + broken: { routes: [], runtime: "edge" }, + }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(true); + expect(result.message).toMatch(/Splitted function broken must have at least one route/); + }); + + test("returns shouldThrow:false for incompatible wrapper and converter", () => { + const config = { + default: { override: { wrapper: "aws-lambda", converter: "edge" } }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(false); + expect(result.level).toBe("error"); + expect(result.message).toMatch(/not compatible/); + }); + + test("returns shouldThrow:false for disabled incremental cache warning", () => { + const config = { + default: {}, + dangerous: { disableIncrementalCache: true }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(false); + expect(result.level).toBe("warn"); + expect(result.message).toMatch(/disabled incremental cache/); + }); + + test("returns shouldThrow:false for disabled tag cache warning", () => { + const config = { + default: {}, + dangerous: { disableTagCache: true }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(false); + expect(result.level).toBe("warn"); + expect(result.message).toMatch(/disabled tag cache/); + }); +}); diff --git a/packages/core/src/build/validateConfig.ts b/packages/core/src/build/validateConfig.ts index 22779d08..b8847e09 100644 --- a/packages/core/src/build/validateConfig.ts +++ b/packages/core/src/build/validateConfig.ts @@ -6,7 +6,13 @@ import type { SplittedFunctionOptions, } from "@/types/open-next"; -import logger from "../logger.js"; +export type ValidateConfigResult = { + success: boolean; + message?: string; + shouldThrow?: boolean; + /** Logging level the caller should use when shouldThrow is false. Defaults to "warn". */ + level?: "warn" | "error"; +}; const compatibilityMatrix: Record = { "aws-lambda": ["aws-apigw-v1", "aws-apigw-v2", "aws-cloudfront", "sqs-revalidate"], @@ -20,73 +26,117 @@ const compatibilityMatrix: Record = { dummy: ["dummy"], }; -function validateFunctionOptions(fnOptions: FunctionOptions) { +function validateFunctionOptions(fnOptions: FunctionOptions): ValidateConfigResult { + // TODO: validateConfig needs to be updated to normalize full-path override strings to bare names before the compatibilityMatrix lookup (full-path user overrides currently crash L41) const wrapper = typeof fnOptions.override?.wrapper === "string" ? fnOptions.override.wrapper : "aws-lambda"; const converter = typeof fnOptions.override?.converter === "string" ? fnOptions.override.converter : "aws-apigw-v2"; if (fnOptions.override?.generateDockerfile && converter !== "node" && wrapper !== "node") { - logger.warn( - "You've specified generateDockerfile without node converter and wrapper. Without custom converter and wrapper the dockerfile will not work" - ); + return { + success: false, + shouldThrow: false, + level: "warn", + message: + "You've specified generateDockerfile without node converter and wrapper. Without custom converter and wrapper the dockerfile will not work", + }; } if (converter === "aws-cloudfront" && fnOptions.placement !== "global") { - logger.warn( - "You've specified aws-cloudfront converter without global placement. This may not generate the correct output" - ); + return { + success: false, + shouldThrow: false, + level: "warn", + message: + "You've specified aws-cloudfront converter without global placement. This may not generate the correct output", + }; } const isCustomWrapper = typeof fnOptions.override?.wrapper === "function"; const isCustomConverter = typeof fnOptions.override?.converter === "function"; // Check if the wrapper and converter are compatible // Only check if using one of the included converters or wrapper if (!compatibilityMatrix[wrapper].includes(converter) && !isCustomWrapper && !isCustomConverter) { - logger.error( - `Wrapper ${wrapper} and converter ${converter} are not compatible. For the wrapper ${wrapper} you should only use the following converters: ${compatibilityMatrix[ + return { + success: false, + shouldThrow: false, + level: "error", + message: `Wrapper ${wrapper} and converter ${converter} are not compatible. For the wrapper ${wrapper} you should only use the following converters: ${compatibilityMatrix[ wrapper - ].join(", ")}` - ); + ].join(", ")}`, + }; } + return { success: true }; } -function validateSplittedFunctionOptions(fnOptions: SplittedFunctionOptions, name: string) { - validateFunctionOptions(fnOptions); +function validateSplittedFunctionOptions( + fnOptions: SplittedFunctionOptions, + name: string +): ValidateConfigResult { + const fnResult = validateFunctionOptions(fnOptions); + if (!fnResult.success) return fnResult; if (fnOptions.routes.length === 0) { - throw new Error(`Splitted function ${name} must have at least one route`); + return { + success: false, + shouldThrow: true, + message: `Splitted function ${name} must have at least one route`, + }; } // Check if the routes are properly formated - fnOptions.routes.forEach((route) => { + for (const route of fnOptions.routes) { if (!route.startsWith("app/") && !route.startsWith("pages/")) { - throw new Error( - `Route ${route} in function ${name} is not a valid route. It should starts with app/ or pages/ depending on if you use page or app router` - ); + return { + success: false, + shouldThrow: true, + message: `Route ${route} in function ${name} is not a valid route. It should starts with app/ or pages/ depending on if you use page or app router`, + }; } - }); + } if (fnOptions.runtime === "edge" && fnOptions.routes.length > 1) { - throw new Error(`Edge function ${name} can only have one route`); + return { + success: false, + shouldThrow: true, + message: `Edge function ${name} can only have one route`, + }; } + return { success: true }; } -export function validateConfig(config: OpenNextConfig) { - validateFunctionOptions(config.default); - Object.entries(config.functions ?? {}).forEach(([name, fnOptions]) => { - validateSplittedFunctionOptions(fnOptions, name); - }); +export function validateConfig(config: OpenNextConfig): ValidateConfigResult { + const defaultResult = validateFunctionOptions(config.default); + if (!defaultResult.success) return defaultResult; + for (const [name, fnOptions] of Object.entries(config.functions ?? {})) { + const splittedResult = validateSplittedFunctionOptions(fnOptions, name); + if (!splittedResult.success) return splittedResult; + } if (config.dangerous?.disableIncrementalCache) { - logger.warn("You've disabled incremental cache. This means that ISR and SSG will not work."); + return { + success: false, + shouldThrow: false, + level: "warn", + message: "You've disabled incremental cache. This means that ISR and SSG will not work.", + }; } if (config.dangerous?.disableTagCache) { - logger.warn( - `You've disabled tag cache. + return { + success: false, + shouldThrow: false, + level: "warn", + message: `You've disabled tag cache. This means that revalidatePath and revalidateTag from next/cache will not work. - It is safe to disable if you only use page router` - ); + It is safe to disable if you only use page router`, + }; } - validateFunctionOptions(config.imageOptimization ?? {}); + const imageOptimizationResult = validateFunctionOptions(config.imageOptimization ?? {}); + if (!imageOptimizationResult.success) return imageOptimizationResult; if (config.middleware?.external === true) { - validateFunctionOptions(config.middleware ?? {}); + const middlewareResult = validateFunctionOptions(config.middleware ?? {}); + if (!middlewareResult.success) return middlewareResult; } //@ts-expect-error - Revalidate custom wrapper type is different - validateFunctionOptions(config.revalidate ?? {}); + const revalidateResult = validateFunctionOptions(config.revalidate ?? {}); + if (!revalidateResult.success) return revalidateResult; //@ts-expect-error - Warmer custom wrapper type is different - validateFunctionOptions(config.warmer ?? {}); - validateFunctionOptions(config.initializationFunction ?? {}); + const warmerResult = validateFunctionOptions(config.warmer ?? {}); + if (!warmerResult.success) return warmerResult; + const initResult = validateFunctionOptions(config.initializationFunction ?? {}); + if (!initResult.success) return initResult; + return { success: true }; } 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..a158742d 100644 --- a/packages/core/src/core/createMainHandler.ts +++ b/packages/core/src/core/createMainHandler.ts @@ -6,12 +6,11 @@ import { generateUniqueId } from "../adapters/util"; import { openNextHandler } from "./requestHandler"; import { resolveAssetResolver, + resolveCache, resolveCdnInvalidation, resolveConverter, - resolveIncrementalCache, resolveProxyRequest, resolveQueue, - resolveTagCache, resolveWrapper, } from "./resolve"; @@ -31,16 +30,14 @@ 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 ); } + 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 49d117e7..6b09e35a 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; @@ -19,7 +19,9 @@ export async function resolveConverter< if (typeof converter === "function") { return converter(); } - const m_1 = (await import("../overrides/converters/node.js")) as unknown as { default: Converter }; + const m_1 = (await import("../overrides/converters/node.js")) as unknown as { + default: Converter; + }; return m_1.default; } @@ -30,7 +32,9 @@ export async function resolveWrapper< if (typeof wrapper === "function") { return wrapper(); } - const m_1 = (await import("../overrides/wrappers/node.js")) as unknown as { default: Wrapper }; + const m_1 = (await import("../overrides/wrappers/node.js")) as unknown as { + default: Wrapper; + }; return m_1.default; } @@ -40,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(); } @@ -68,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(); } @@ -153,3 +161,14 @@ export async function resolveCdnInvalidation(cdnInvalidation: OverrideOptions["c const m_1 = await import("../overrides/cdnInvalidation/dummy.js"); return m_1.default; } + +/** + * @__PURE__ + */ +export async function resolveCache(cache: OverrideOptions["cache"]): Promise { + 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 271b123f..0e711802 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"; @@ -35,7 +34,8 @@ async function computeCacheControl( body: string, host: string, revalidate?: number | false, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ) { let finalRevalidate = CACHE_ONE_YEAR; @@ -60,19 +60,26 @@ async function computeCacheControl( etag, }; } - if (finalRevalidate !== CACHE_ONE_YEAR) { - const sMaxAge = Math.max(finalRevalidate - age, 1); + + // SSG uses one year cache + const isSSG = finalRevalidate === CACHE_ONE_YEAR; + const remainingTtl = Math.max(finalRevalidate - age, 1); + + const isStaleFromTime = !isSSG && remainingTtl === 1; + const isStale = isStaleFromTime || isStaleFromTagCache; + + if (!isSSG || isStaleFromTagCache) { + const sMaxAge = isStaleFromTagCache ? 1 : remainingTtl; debug("sMaxAge", { finalRevalidate, age, lastModified, revalidate, + isStaleFromTagCache, }); - const isStale = sMaxAge === 1; if (isStale) { let url = NextConfig.trailingSlash ? `${path}/` : path; if (NextConfig.basePath) { - // We need to add the basePath to the url url = `${NextConfig.basePath}${url}`; } await globalThis.queue.send({ @@ -165,7 +172,8 @@ async function generateResult( event: MiddlewareEvent, localizedPath: string, cachedValue: CacheValue<"cache">, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ): Promise { debug("Returning result from experimental cache"); let body = ""; @@ -232,7 +240,8 @@ async function generateResult( body, event.headers.host, cachedValue.revalidate, - lastModified + lastModified, + isStaleFromTagCache ); return { type: "core", @@ -340,36 +349,34 @@ export async function cacheInterceptor( } else if (localizedPath === "") { pathToUse = "/index"; } - const cachedData = await 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; + //TODO: change returned type to provide staleness as a prop + // Detect staleness signaled by the cache adapter (sets lastModified to 1) + const isStaleFromTagCache = cachedData.lastModified === 1; switch (cachedData?.value?.type) { case "app": case "page": - return generateResult(event, localizedPath, cachedData.value, cachedData.lastModified); + return generateResult( + event, + localizedPath, + cachedData.value, + cachedData.lastModified, + isStaleFromTagCache + ); case "redirect": { const cacheControl = await computeCacheControl( localizedPath, "", host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); return { type: "core", @@ -388,7 +395,8 @@ export async function cacheInterceptor( cachedData.value.body, host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"])); 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..b5d53a01 --- /dev/null +++ b/packages/core/src/overrides/cache/fetch.ts @@ -0,0 +1,44 @@ +import type { Cache } from "@/types/overrides"; +import { parseCacheGetResponse } from "@/utils/cache-get"; + +const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; + +const fetchCache: Cache = { + name: "fetch-cache", + 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" }); + 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) => { + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; + await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }, + delete: async (key) => { + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; + await fetch(url, { method: "DELETE" }); + }, + revalidateTags: async (tags, durations) => { + await fetch(`${CACHE_URL}/cache/revalidate-tags`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags, durations }), + }); + }, +}; + +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..089d50bb --- /dev/null +++ b/packages/core/src/overrides/cache/local.ts @@ -0,0 +1,95 @@ +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 } from "@/utils/stream"; + +let handler: ((event: InternalEvent) => Promise) | null = null; + +async function getHandler() { + if (!handler) { + const cacheHandlerPath = path.join(getMonorepoRelativePath(), "cache-function/index.mjs"); + const m = await import(cacheHandlerPath); + handler = m.handler; + } + return handler; +} + +const localCache: Cache = { + name: "local-cache", + 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, + cookies: {}, + remoteAddress: "127.0.0.1", + }; + const result = await h(event); + const bodyText = 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}`; + const event: InternalEvent = { + type: "core", + method: "PUT", + rawPath: `/cache/${encodedKey}`, + url, + headers: { "Content-Type": "application/json" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + body: Buffer.from(JSON.stringify({ value })), + }; + await h(event); + }, + 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", + }; + await h(event); + }, + revalidateTags: async (tags, durations) => { + 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: Buffer.from(JSON.stringify({ tags, durations })), + }; + await h(event); + }, +}; + +export default localCache; diff --git a/packages/core/src/overrides/tagCache/dummy.ts b/packages/core/src/overrides/tagCache/dummy.ts index ac44b532..8a62dfa8 100644 --- a/packages/core/src/overrides/tagCache/dummy.ts +++ b/packages/core/src/overrides/tagCache/dummy.ts @@ -13,6 +13,9 @@ const dummyTagCache: TagCache = { getLastModified: async (_: string, lastModified) => { return lastModified ?? Date.now(); }, + isStale: async () => { + return false; + }, writeTags: async () => { return; }, diff --git a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts index 49ccb498..08b6ddb9 100644 --- a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts @@ -1,8 +1,14 @@ -import type { NextModeTagCache } from "@/types/overrides"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@/types/overrides"; import { debug } from "../../adapters/logger"; -const tagsMap = new Map(); +type TagData = { + revalidatedAt: number; + stale?: number; + expire?: number; +}; + +const tagsMap = new Map(); export default { name: "fs-dev-nextMode", @@ -15,7 +21,7 @@ export default { let lastRevalidated = 0; tags.forEach((tag) => { - const tagTime = tagsMap.get(tag); + const tagTime = tagsMap.get(tag)?.revalidatedAt; if (tagTime && tagTime > lastRevalidated) { lastRevalidated = tagTime; } @@ -30,22 +36,49 @@ export default { } const hasRevalidatedTag = tags.some((tag) => { - const tagRevalidatedAt = tagsMap.get(tag); - return tagRevalidatedAt ? tagRevalidatedAt > (lastModified ?? 0) : false; + const tagData = tagsMap.get(tag); + return tagData ? tagData.revalidatedAt > (lastModified ?? 0) : false; }); debug("hasBeenRevalidated result:", hasRevalidatedTag); return hasRevalidatedTag; }, - writeTags: async (tags: string[]) => { + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + + const hasStaleTag = tags.some((tag) => { + const tagData = tagsMap.get(tag); + if (!tagData || typeof tagData.stale !== "number") { + return false; + } + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return tagData.revalidatedAt > (lastModified ?? 0) && tagData.stale >= (lastModified ?? 0); + }); + debug("isStale result:", hasStaleTag); + return hasStaleTag; + }, + writeTags: async (tags: (string | NextModeTagCacheWriteInput)[]) => { if (globalThis.openNextConfig.dangerous?.disableTagCache || tags.length === 0) { return; } - debug("writeTags", { tags: tags }); + debug("writeTags", { tags }); + const now = Date.now(); tags.forEach((tag) => { - tagsMap.set(tag, Date.now()); + if (typeof tag === "string") { + tagsMap.set(tag, { revalidatedAt: now }); + } else { + tagsMap.set(tag.tag, { + revalidatedAt: now, + ...(tag.stale !== undefined ? { stale: tag.stale } : {}), + ...(tag.expire !== undefined ? { expire: tag.expire } : {}), + }); + } }); debug("writeTags completed, written", tags.length, "tags"); diff --git a/packages/core/src/overrides/tagCache/fs-dev.ts b/packages/core/src/overrides/tagCache/fs-dev.ts index 932eb216..1313b620 100644 --- a/packages/core/src/overrides/tagCache/fs-dev.ts +++ b/packages/core/src/overrides/tagCache/fs-dev.ts @@ -1,17 +1,21 @@ import fs from "node:fs"; import path from "node:path"; -import type { TagCache } from "@/types/overrides"; +import type { OriginalTagCacheWriteInput, TagCache } from "@/types/overrides"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; const tagFile = path.join(getMonorepoRelativePath(), "dynamodb-provider/dynamodb-cache.json"); const tagContent = fs.readFileSync(tagFile, "utf-8"); -let tags = JSON.parse(tagContent) as { +type TagEntry = { tag: { S: string }; path: { S: string }; revalidatedAt: { N: string }; -}[]; + stale?: { N: string }; + expire?: { N: string }; +}; + +let tags = JSON.parse(tagContent) as TagEntry[]; const { NEXT_BUILD_ID } = process.env; @@ -40,7 +44,20 @@ const tagCache: TagCache = { ); return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); }, - writeTags: async (newTags) => { + isStale: async (path: string, lastModified?: number) => { + const matchingTags = tags.filter((tagPathMapping) => tagPathMapping.path.S === buildKey(path)); + return matchingTags.some((entry) => { + if (!entry.stale?.N) return false; + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return ( + Number.parseInt(entry.revalidatedAt.N) > (lastModified ?? 0) && + Number.parseInt(entry.stale.N) > (lastModified ?? 0) + ); + }); + }, + writeTags: async (newTags: OriginalTagCacheWriteInput[]) => { const newTagsSet = new Set(newTags.map(({ tag, path }) => `${buildKey(tag)}-${buildKey(path)}`)); const unchangedTags = tags.filter(({ tag, path }) => !newTagsSet.has(`${tag.S}-${path.S}`)); tags = unchangedTags.concat( @@ -48,6 +65,8 @@ const tagCache: TagCache = { tag: { S: buildKey(item.tag) }, path: { S: buildKey(item.path) }, revalidatedAt: { N: `${item.revalidatedAt ?? Date.now()}` }, + ...(item.stale !== undefined ? { stale: { N: `${item.stale}` } } : {}), + ...(item.expire !== undefined ? { expire: { N: `${item.expire}` } } : {}), })) ); }, diff --git a/packages/core/src/plugins/resolve.spec.ts b/packages/core/src/plugins/resolve.spec.ts new file mode 100644 index 00000000..749e6e34 --- /dev/null +++ b/packages/core/src/plugins/resolve.spec.ts @@ -0,0 +1,325 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { build } from "esbuild"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { openNextResolvePlugin } from "./resolve.js"; + +// Synthetic resolve.js module body mirroring compiled output with relative-path imports. +// Each function has exactly ONE await import with a relative ../overrides/ path. +const FIXTURE_CONTENT = ` +export async function resolveConverter(converter) { + if (typeof converter === "function") return converter(); + const m_1 = await import("../overrides/converters/node.js"); + return m_1.default; +} +export async function resolveWrapper(wrapper) { + if (typeof wrapper === "function") return wrapper(); + const m_1 = await import("../overrides/wrappers/node.js"); + return m_1.default; +} +export async function resolveTagCache(tagCache) { + if (typeof tagCache === "function") return tagCache(); + const m_1 = await import("../overrides/tagCache/fs-dev-nextMode.js"); + return m_1.default; +} +export async function resolveQueue(queue) { + if (typeof queue === "function") return queue(); + const m_1 = await import("../overrides/queue/direct.js"); + return m_1.default; +} +export async function resolveIncrementalCache(incrementalCache) { + if (typeof incrementalCache === "function") return incrementalCache(); + const m_1 = await import("../overrides/incrementalCache/fs-dev.js"); + return m_1.default; +} +export async function resolveImageLoader(imageLoader) { + if (typeof imageLoader === "function") return imageLoader(); + const m_1 = await import("../overrides/imageLoader/fs-dev.js"); + return m_1.default; +} +export async function resolveOriginResolver(originResolver) { + if (typeof originResolver === "function") return originResolver(); + const m_1 = await import("../overrides/originResolver/pattern-env.js"); + return m_1.default; +} +export async function resolveWarmerInvoke(warmer) { + if (typeof warmer === "function") return warmer(); + const m_1 = await import("../overrides/warmer/dummy.js"); + return m_1.default; +} +export async function resolveProxyRequest(proxyRequest) { + if (typeof proxyRequest === "function") return proxyRequest(); + const m_1 = await import("../overrides/proxyExternalRequest/node.js"); + return m_1.default; +} +export async function resolveCdnInvalidation(cdnInvalidation) { + if (typeof cdnInvalidation === "function") return cdnInvalidation(); + const m_1 = await import("../overrides/cdnInvalidation/dummy.js"); + return m_1.default; +} +`.trim(); + +// The default overrides imported by the fixture above, and the alternatives the +// tests redirect to. +const OVERRIDE_MODULES = [ + "overrides/converters/node.js", + "overrides/converters/edge.js", + "overrides/wrappers/node.js", + "overrides/wrappers/cloudflare-edge.js", + "overrides/tagCache/fs-dev-nextMode.js", + "overrides/queue/direct.js", + "overrides/incrementalCache/fs-dev.js", + "overrides/imageLoader/fs-dev.js", + "overrides/originResolver/pattern-env.js", + "overrides/warmer/dummy.js", + "overrides/proxyExternalRequest/node.js", + "overrides/cdnInvalidation/dummy.js", +]; + +// Packages resolved through node_modules for the full-path override cases. +const CORE_PKG_MODULES = [ + "overrides/converters/edge.js", + "overrides/converters/dummy.js", + "overrides/imageLoader/dummy.js", + "overrides/originResolver/dummy.js", + "overrides/proxyExternalRequest/fetch.js", +]; +const AWS_PKG_MODULES = [ + "overrides/wrappers/aws-lambda.js", + "overrides/wrappers/aws-lambda-streaming.js", + "overrides/converters/aws-apigw-v2.js", + "overrides/tagCache/dynamodb.js", + "overrides/queue/sqs.js", + "overrides/incrementalCache/s3.js", + "overrides/warmer/aws-lambda.js", + "overrides/cdnInvalidation/cloudfront.js", +]; + +let root: string; + +/** Writes a module exporting a marker identifying it by its path in the fixture. */ +async function writeModule(relPath: string) { + const fullPath = join(root, relPath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, `export default "MARKER:${relPath}";`, "utf-8"); +} + +/** Marker bundled for an override living next to the fixture `resolve.js`. */ +function local(relPath: string) { + return `MARKER:overrides/${relPath}`; +} + +/** Marker bundled for an override coming from a package in `node_modules`. */ +function pkg(name: string, relPath: string) { + return `MARKER:node_modules/${name}/overrides/${relPath}`; +} + +/** Bundles the fixture with the plugin and returns the generated code. */ +async function bundleWithPlugin(opts: Parameters[0], entry = "entry.js") { + const result = await build({ + entryPoints: [join(root, entry)], + absWorkingDir: root, + bundle: true, + write: false, + format: "esm", + platform: "node", + outfile: join(root, "out.js"), + plugins: [openNextResolvePlugin(opts)], + }); + return result.outputFiles[0].text; +} + +describe("openNextResolvePlugin", () => { + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "resolve-test-")); + + await mkdir(join(root, "core"), { recursive: true }); + await writeFile(join(root, "core", "resolve.js"), FIXTURE_CONTENT, "utf-8"); + await writeFile(join(root, "entry.js"), `export * from "./core/resolve.js";`, "utf-8"); + + for (const mod of OVERRIDE_MODULES) { + await writeModule(mod); + } + for (const [name, modules] of [ + ["@opennextjs/core", CORE_PKG_MODULES], + ["@opennextjs/aws", AWS_PKG_MODULES], + ] as const) { + await mkdir(join(root, "node_modules", name), { recursive: true }); + await writeFile( + join(root, "node_modules", name, "package.json"), + JSON.stringify({ name, type: "module" }), + "utf-8" + ); + for (const mod of modules) { + await writeModule(join("node_modules", name, mod)); + } + } + }); + + afterAll(async () => { + await rm(root, { recursive: true, force: true }); + }); + + test("A - full-path default verbatim: core full path default replaces anchor", async () => { + const contents = await bundleWithPlugin({ + overrides: {}, + defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, + fnName: "test", + }); + expect(contents).toContain(pkg("@opennextjs/core", "converters/edge.js")); + expect(contents).not.toContain(local("converters/node.js")); + }); + + test("B - cross-package user full aws path wins over core default", async () => { + const contents = await bundleWithPlugin({ + overrides: { converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js" }, + defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, + fnName: "test", + }); + expect(contents).toContain(pkg("@opennextjs/aws", "converters/aws-apigw-v2.js")); + expect(contents).not.toContain(pkg("@opennextjs/core", "converters/edge.js")); + }); + + test("C - no-op anchor stays: no override no default keeps relative core path", async () => { + const contents = await bundleWithPlugin({ + overrides: {}, + defaultOverrides: {}, + fnName: "test", + }); + expect(contents).toContain(local("converters/node.js")); + }); + + test("D - 10-key mixed aws+core full paths all rewritten", async () => { + const contents = await bundleWithPlugin({ + overrides: {}, + defaultOverrides: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/core/overrides/converters/edge.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + queue: "@opennextjs/aws/overrides/queue/sqs.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", + imageLoader: "@opennextjs/core/overrides/imageLoader/dummy.js", + originResolver: "@opennextjs/core/overrides/originResolver/dummy.js", + warmer: "@opennextjs/aws/overrides/warmer/aws-lambda.js", + proxyExternalRequest: "@opennextjs/core/overrides/proxyExternalRequest/fetch.js", + cdnInvalidation: "@opennextjs/aws/overrides/cdnInvalidation/cloudfront.js", + }, + fnName: "test", + }); + expect(contents).toContain(pkg("@opennextjs/aws", "wrappers/aws-lambda.js")); + expect(contents).toContain(pkg("@opennextjs/core", "converters/edge.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "tagCache/dynamodb.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "queue/sqs.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "incrementalCache/s3.js")); + expect(contents).toContain(pkg("@opennextjs/core", "imageLoader/dummy.js")); + expect(contents).toContain(pkg("@opennextjs/core", "originResolver/dummy.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "warmer/aws-lambda.js")); + expect(contents).toContain(pkg("@opennextjs/core", "proxyExternalRequest/fetch.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "cdnInvalidation/cloudfront.js")); + // None of the defaults are bundled anymore + expect(contents).not.toContain(local("wrappers/node.js")); + expect(contents).not.toContain(local("tagCache/fs-dev-nextMode.js")); + expect(contents).not.toContain(local("incrementalCache/fs-dev.js")); + }); + + test("E - deprecated cloudflare bare name becomes legacy relative core path", async () => { + const contents = await bundleWithPlugin({ + overrides: { wrapper: "cloudflare" }, + defaultOverrides: {}, + fnName: "test", + }); + expect(contents).toContain(local("wrappers/cloudflare-edge.js")); + expect(contents).not.toContain(local("wrappers/node.js")); + }); + + test("F - function override becomes full dummy core path", async () => { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any - testing function override + const fnOverride = (() => ({})) as any; + const contents = await bundleWithPlugin({ + overrides: { converter: fnOverride }, + defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, + fnName: "test", + }); + expect(contents).toContain(pkg("@opennextjs/core", "converters/dummy.js")); + expect(contents).not.toContain(pkg("@opennextjs/core", "converters/edge.js")); + }); + + test("G - AWS server defaults produce aws full paths", async () => { + const contents = await bundleWithPlugin({ + overrides: {}, + defaultOverrides: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.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", + }, + fnName: "server", + }); + expect(contents).toContain(pkg("@opennextjs/aws", "wrappers/aws-lambda-streaming.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "converters/aws-apigw-v2.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "incrementalCache/s3.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "tagCache/dynamodb.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "queue/sqs.js")); + // Keys without an override keep their default + expect(contents).toContain(local("imageLoader/fs-dev.js")); + }); + + test("H - bare-name user override becomes legacy relative core path", async () => { + const contents = await bundleWithPlugin({ + overrides: { converter: "edge" }, + defaultOverrides: {}, + fnName: "test", + }); + expect(contents).toContain(local("converters/edge.js")); + expect(contents).not.toContain(local("converters/node.js")); + }); + + test("I - resolvable package specifier is resolved through node_modules", async () => { + await mkdir(join(root, "node_modules", "@test-pkg", "wrapper"), { recursive: true }); + await writeFile( + join(root, "node_modules", "@test-pkg", "wrapper", "package.json"), + JSON.stringify({ name: "@test-pkg/wrapper", main: "index.js" }), + "utf-8" + ); + await writeModule(join("node_modules", "@test-pkg", "wrapper", "index.js")); + + const contents = await bundleWithPlugin({ + overrides: { wrapper: "@test-pkg/wrapper" }, + defaultOverrides: {}, + fnName: "test", + }); + + expect(contents).toContain("MARKER:node_modules/@test-pkg/wrapper/index.js"); + expect(contents).not.toContain(local("wrappers/node.js")); + }); + + test("J - overrides of other modules are left alone", async () => { + await writeFile( + join(root, "core", "other.js"), + `export const load = () => import("../overrides/converters/node.js");`, + "utf-8" + ); + await writeFile( + join(root, "entry-other.js"), + `export * from "./core/resolve.js";\nexport * from "./core/other.js";`, + "utf-8" + ); + + const contents = await bundleWithPlugin( + { + overrides: { converter: "edge" }, + defaultOverrides: {}, + fnName: "test", + }, + "entry-other.js" + ); + + // `resolve.js` gets the override, `other.js` keeps importing the default + expect(contents).toContain(local("converters/edge.js")); + expect(contents).toContain(local("converters/node.js")); + }); +}); diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 1a8521a5..6992d5ad 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -1,18 +1,21 @@ import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, relative } from "node:path"; import chalk from "chalk"; import type { Plugin } from "esbuild"; import type { - BaseOverride, 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"; @@ -23,26 +26,20 @@ 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; proxyExternalRequest?: OverrideOptions["proxyExternalRequest"]; cdnInvalidation?: OverrideOptions["cdnInvalidation"]; + cache?: OverrideOptions["cache"]; }; + defaultOverrides?: DefaultOverrides; fnName?: string; } -function getOverrideOrDummy>(override: Override) { - if (typeof override === "string") { - return override; - } - // We can return dummy here because if it's not a string, it's a LazyLoadedOverride - return "dummy"; -} - // This could be useful in the future to map overrides to nested folders const nameToFolder = { wrapper: "wrappers", @@ -55,52 +52,130 @@ const nameToFolder = { warmer: "warmer", proxyExternalRequest: "proxyExternalRequest", cdnInvalidation: "cdnInvalidation", + cache: "cache", }; -const defaultOverrides = { - wrapper: "node", - converter: "node", - tagCache: "fs-dev-nextMode", - queue: "direct", - incrementalCache: "fs-dev", - imageLoader: "fs-dev", - originResolver: "pattern-env", - warmer: "dummy", - proxyExternalRequest: "node", - cdnInvalidation: "dummy", -}; +export type OverrideKey = keyof typeof nameToFolder; +export type DefaultOverrides = Partial>; + +// `core/resolve.js` lazily imports every default override with a relative specifier +// of the form `../overrides//.js`, and each override key owns exactly +// one folder (see `nameToFolder`). Rewriting the specifier of a folder is therefore +// enough to swap in the configured override, whatever the default file is named. +const resolveModuleFilter = getCrossPlatformPathRegex("core/resolve.js"); + +/** Matches the specifier of the override lazily imported from the given folder. */ +function getOverrideImportRegex(folder: string): RegExp { + return new RegExp(String.raw`(["'])\.\./overrides/${folder}/[^"']+\1`); +} + +export type BundleType = + | "server" + | "middleware" + | "edge" + | "imageOptimization" + | "revalidation" + | "warmer" + | "cache" + | "tagCache"; +export type BundleDefaults = Partial>; + +/** + * Checks if a string is a full package-specifier path (starts with @ or contains /). + * Bare names like "node", "edge", "aws-lambda" return false. + */ +function isFullPath(s: string): boolean { + return s.startsWith("@") || s.includes("/"); +} + +/** + * Turns a package specifier into a path relative to `core/resolve.js`, so that esbuild + * resolves the override itself - it is the only way for it to pick up the module type + * (`type: "module"`) of the package the override comes from. + * + * Overrides live in the adapter packages the app depends on, which are not necessarily + * reachable from `core` itself (they are not, with an isolated node_modules layout), so + * resolution is attempted from the app as well. The specifier is returned as-is when it + * cannot be resolved, leaving esbuild to report the failure. + */ +function toRelativePath(specifier: string, resolvePath: string, appDir: string): string { + for (const from of [resolvePath, join(appDir, "index.js")]) { + try { + const resolved = createRequire(from).resolve(specifier); + return `./${relative(dirname(resolvePath), resolved).replaceAll("\\", "/")}`; + } catch { + // Not resolvable from there, try the next one + } + } + return specifier; +} /** * @param opts.overrides - The name of the overrides to use * @returns */ -export function openNextResolvePlugin({ overrides, fnName }: IPluginSettings): Plugin { +export function openNextResolvePlugin({ + overrides, + defaultOverrides: defaultValues, + fnName, +}: IPluginSettings): Plugin { return { name: "opennext-resolve", setup(build) { logger.debug(chalk.blue("OpenNext Resolve plugin"), fnName ? `for ${fnName}` : ""); - build.onLoad({ filter: getCrossPlatformPathRegex("core/resolve.js") }, async (args) => { + + // Maps the overrides folder to the specifier that should be imported instead + // of the default one. Computed once, the config cannot change during a build. + const redirects = new Map(); + const allKeys = new Set([...Object.keys(overrides ?? {}), ...Object.keys(defaultValues ?? {})]); + + for (const overrideName of allKeys) { + const configValue = overrides?.[overrideName as keyof typeof overrides]; + const defaultValue = defaultValues?.[overrideName as keyof typeof defaultValues]; + let overrideValue = configValue ?? defaultValue; + if (!overrideValue) { + continue; + } + + const folder = nameToFolder[overrideName as OverrideKey]; + if (!folder) { + continue; + } + + if (overrideName === "wrapper" && overrideValue === "cloudflare") { + overrideValue = "cloudflare-edge"; + } + + if (typeof overrideValue !== "string") { + // Lazy loaded overrides are inlined in the config, only the default + // import has to be dropped - the dummy is never actually used. + redirects.set(folder, `@opennextjs/core/overrides/${folder}/dummy.js`); + } else if (isFullPath(overrideValue)) { + redirects.set(folder, overrideValue); + } else { + redirects.set(folder, `../overrides/${folder}/${overrideValue}.js`); + } + } + + if (redirects.size === 0) { + return; + } + + const appDir = build.initialOptions.absWorkingDir ?? process.cwd(); + + build.onLoad({ filter: resolveModuleFilter }, async (args) => { let contents = await readFile(args.path, "utf-8"); - const overridesEntries = Object.entries(overrides ?? {}); - for (let [overrideName, overrideValue] of overridesEntries) { - if (!overrideValue) { - continue; - } - if (overrideName === "wrapper" && overrideValue === "cloudflare") { - // "cloudflare" is deprecated and replaced by "cloudflare-edge". - overrideValue = "cloudflare-edge"; - } - const folder = nameToFolder[overrideName as keyof typeof nameToFolder]; - const defaultOverride = defaultOverrides[overrideName as keyof typeof defaultOverrides]; - - contents = contents.replace( - `../overrides/${folder}/${defaultOverride}.js`, - `../overrides/${folder}/${getOverrideOrDummy(overrideValue)}.js` - ); + + for (const [folder, specifier] of redirects) { + const targetPath = specifier.startsWith(".") + ? specifier + : toRelativePath(specifier, args.path, appDir); + logger.debug(chalk.blue(`Resolving the ${folder} override to "${targetPath}"`)); + // JSON encoded: the path can contain characters to escape (Windows separators) + contents = contents.replace(getOverrideImportRegex(folder), () => JSON.stringify(targetPath)); } - return { - contents, - }; + + return { contents }; }); }, }; diff --git a/packages/core/src/types/cache.ts b/packages/core/src/types/cache.ts index db5b63f1..1737d0c4 100644 --- a/packages/core/src/types/cache.ts +++ b/packages/core/src/types/cache.ts @@ -168,6 +168,10 @@ export interface ComposableCacheHandler { * Removed from Next.js 16 */ expireTags(...tags: string[]): Promise; + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + */ + updateTags?(tags: string[], durations?: { expire?: number }): Promise; /** * This function is only there for older versions and do nothing */ diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 289fe144..6027798d 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, @@ -11,6 +12,7 @@ import type { } from "@/types/overrides"; import type { DetachedPromiseRunner } from "../utils/promise"; +import type { RequestCache } from "../utils/requestCache"; import type { i18nConfig } from "./next-types.js"; import type { OpenNextConfig, WaitUntil } from "./open-next"; @@ -68,21 +70,23 @@ interface OpenNextRequestContext { waitUntil?: WaitUntil; /** We use this to deduplicate write of the tags*/ writtenTags: Set; + /** Per-request in-memory cache. Overrides can use this to store data scoped to the current request. */ + requestCache: RequestCache; } 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; @@ -195,6 +199,19 @@ declare global { */ var openNextVersion: string; + /** + * The version of Next.js used in this build. + * Available in the cache function (defined in the esbuild banner of the cache bundle). + */ + var nextVersion: string; + + /** + * The cache client used to communicate with the cache handler function. + * Only available in main functions. + * Defined in `createMainHandler`. + */ + var cache: Cache; + /** * 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 8216e491..a7045250 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, @@ -221,6 +222,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"; @@ -251,18 +254,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" @@ -280,6 +271,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 { @@ -482,6 +480,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 84589959..ae4d079f 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -153,12 +153,19 @@ Cons : - One page request (i.e. GET request) could require to check a lot of tags (And some of them multiple time when used with the fetch cache) - Almost impossible to do automatic cdn revalidation by itself */ +export interface NextModeTagCacheWriteInput { + tag: string; + stale?: number; + expire?: number; +} + export type NextModeTagCache = BaseTagCache & { mode: "nextMode"; // Necessary for the composable cache getLastRevalidated(tags: string[]): Promise; hasBeenRevalidated(tags: string[], lastModified?: number): Promise; - writeTags(tags: string[]): Promise; + isStale?(tags: string[], lastModified?: number): Promise; + writeTags(tags: (string | NextModeTagCacheWriteInput)[]): Promise; // Optional method to get paths by tags // It is used to automatically invalidate paths in the CDN getPathsByTags?: (tags: string[]) => Promise; @@ -168,6 +175,8 @@ export interface OriginalTagCacheWriteInput { tag: string; path: string; revalidatedAt?: number; + stale?: number; + expire?: number; } /** @@ -194,6 +203,7 @@ export type OriginalTagCache = BaseTagCache & { getByTag(tag: string): Promise; getByPath(path: string): Promise; getLastModified(path: string, lastModified?: number): Promise; + isStale?(path: string, lastModified?: number): Promise; writeTags(tags: OriginalTagCacheWriteInput[]): Promise; }; @@ -253,6 +263,21 @@ export type ProxyExternalRequest = BaseOverride & { proxy: (event: InternalEvent) => Promise; }; +export type Cache = BaseOverride & { + get( + key: string, + cacheType?: CacheType, + additionalTags?: string[] + ): Promise> | null>; + set( + key: string, + value: CacheValue, + isFetch?: CacheType + ): Promise; + delete(key: string): Promise; + revalidateTags(tags: string[], durations?: { expire?: number }): 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..572a9a42 --- /dev/null +++ b/packages/core/src/utils/cache-get.ts @@ -0,0 +1,184 @@ +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; +} + +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 = getHeaderNumber(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 = getHeaderNumber(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/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index 090bc247..6af52bf3 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -1,16 +1,35 @@ import type { CacheEntryType, CacheValue, + NextModeTagCacheWriteInput, OriginalTagCacheWriteInput, + TagCache, WithLastModified, } from "@/types/overrides"; import { debug } from "../adapters/logger"; +export async function isStale( + key: string, + tags: string[], + lastModified: number, + tagCache: TagCache = globalThis.tagCache +): Promise { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + if (tagCache.mode === "nextMode") { + return tags.length > 0 && (await tagCache.isStale?.(tags, lastModified)) === true; + } + const isCacheStale = await tagCache.isStale?.(key, lastModified); + return isCacheStale === true; +} + 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 +43,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; } @@ -46,17 +65,25 @@ export function getTagsFromValue(value?: CacheValue<"cache">) { } } -function getTagKey(tag: string | OriginalTagCacheWriteInput): string { +type WriteTagInput = string | NextModeTagCacheWriteInput | OriginalTagCacheWriteInput; + +function getTagKey(tag: WriteTagInput): string { if (typeof tag === "string") { return tag; } - return JSON.stringify({ - tag: tag.tag, - path: tag.path, - }); + if ("path" in tag) { + return JSON.stringify({ + tag: tag.tag, + path: tag.path, + }); + } + return JSON.stringify({ tag: tag.tag }); } -export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): Promise { +export async function writeTags( + tags: WriteTagInput[], + tagCache: TagCache = globalThis.tagCache +): Promise { const store = globalThis.__openNextAls.getStore(); debug("Writing tags", tags, store); if (!store || globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -75,8 +102,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/core/src/utils/promise.ts b/packages/core/src/utils/promise.ts index 00602ce0..1dbc4df6 100644 --- a/packages/core/src/utils/promise.ts +++ b/packages/core/src/utils/promise.ts @@ -1,6 +1,7 @@ import type { WaitUntil } from "@/types/open-next"; import { debug, error } from "../adapters/logger"; +import { RequestCache } from "./requestCache"; /** * A `Promise.withResolvers` implementation that exposes the `resolve` and @@ -113,14 +114,15 @@ export function runWithOpenNextRequestContext( }, fn: () => Promise ): Promise { - return globalThis.__openNextAls.run( - { - requestId, - pendingPromiseRunner: new DetachedPromiseRunner(), - isISRRevalidation, - waitUntil, - writtenTags: new Set(), - }, + return globalThis.__openNextAls.run( + { + requestId, + pendingPromiseRunner: new DetachedPromiseRunner(), + isISRRevalidation, + waitUntil, + writtenTags: new Set(), + requestCache: new RequestCache(), + }, async () => { provideNextAfterProvider(); let result: T; diff --git a/packages/core/src/utils/requestCache.ts b/packages/core/src/utils/requestCache.ts new file mode 100644 index 00000000..20b6ffd9 --- /dev/null +++ b/packages/core/src/utils/requestCache.ts @@ -0,0 +1,28 @@ +/** + * A per-request cache that provides named Map instances. + * Overrides can use this to store and share data within the scope of a single request + * without polluting global state. + * + * Retrieve it from the ALS context: + * ```ts + * const store = globalThis.__openNextAls.getStore(); + * const myMap = store?.requestCache.getOrCreate("my-override"); + * ``` + */ +export class RequestCache { + private _caches = new Map>(); + + /** + * Returns the Map registered under `key`. + * If no Map exists yet for that key, a new empty Map is created, stored, and returned. + * Repeated calls with the same key always return the **same** Map instance. + */ + getOrCreate(key: string): Map { + let cache = this._caches.get(key) as Map | undefined; + if (!cache) { + cache = new Map(); + this._caches.set(key, cache); + } + return cache; + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 02984337..c2ed56d4 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,5 +15,6 @@ "@/utils/*": ["./src/utils/*"] }, "ignoreDeprecations": "6.0" - } + }, + "exclude": ["src/**/*.spec.ts", "dist"] } 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..b420cfc0 --- /dev/null +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -0,0 +1,773 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { handler } from "@opennextjs/core/adapters/cache-adapter"; +import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { fromReadableStream } 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(), +})); + +const mockTagCache = vi.hoisted(() => ({ + name: "mock", + mode: "original", + getByTag: vi.fn(), + getByPath: vi.fn(), + getLastModified: vi.fn(), + isStale: vi.fn(), + writeTags: vi.fn(), + hasBeenRevalidated: vi.fn(), + getPathsByTags: undefined as Mock | undefined, +})); + +const mockCdnInvalidationHandler = vi.hoisted(() => ({ + name: "mock", + invalidatePaths: vi.fn(), +})); + +vi.mock("@opennextjs/core/core/resolve", () => ({ + resolveIncrementalCache: mockResolveIncrementalCache, + resolveTagCache: mockResolveTagCache, + resolveCdnInvalidation: mockResolveCdnInvalidation, +})); + +vi.mock("@opennextjs/core/core/createGenericHandler", () => ({ + createGenericHandler: vi.fn( + async ({ + handler: h, + }: { + handler: (event: InternalEvent, options?: unknown) => Promise; + }) => { + //@ts-ignore + globalThis.openNextConfig = { + dangerous: {}, + } as Partial; + return async (event: InternalEvent, options?: unknown) => h(event, options); + } + ), +})); + +function createEvent(overrides: Partial = {}): InternalEvent { + return { + type: "core", + method: "GET", + rawPath: "/cache/test-key", + url: "https://on/cache/test-key", + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + ...overrides, + }; +} + +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-adapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + 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; + }); + + 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 return cached value when there are no tags", async () => { + mockTagCache.mode = "original"; + 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(); + }); + + 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 skip tag revalidation when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + 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(); + }); + + it("should set lastModified to 1 when tags are stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.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(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + }); + + it("should keep original lastModified when tags are not stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.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(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + }); + + it("should skip isStale when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.isStale).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: Buffer.from("") }); + 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: Buffer.from(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: Buffer.from("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: Buffer.from(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: Buffer.from(JSON.stringify({ value })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should skip tag writing in nextMode", async () => { + mockTagCache.mode = "nextMode"; + const event = createEvent({ + method: "PUT", + body: Buffer.from(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: Buffer.from(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"]); + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(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: Buffer.from(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(500); + }); + }); + + 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(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); + }); + }); + + 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: Buffer.from(""), + }); + 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: Buffer.from(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: Buffer.from(JSON.stringify({ tags: [] })), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is invalid JSON", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from("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: Buffer.from(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: Buffer.from(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: Buffer.from(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: Buffer.from(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: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(500); + }); + + it("should accept durations and pass stale/expire - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should accept durations and pass stale/expire - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should use immediate expiration when no durations provided - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag1", expire: 100000 }]); + }); + + it("should use immediate expiration when no durations provided - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ path: "/path1", tag: "tag1", expire: 100000 }]); + }); + }); +}); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 347f35c1..1d28ed49 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,59 +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 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: { @@ -69,15 +51,13 @@ describe("CacheHandler", () => { }, }; globalThis.isNextAfter15 = false; - tagCache.mode = "original"; - tagCache.getPathsByTags = undefined; }); 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(); }); @@ -88,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 cache.set("key", undefined); + await instance.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(); @@ -161,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: "{}", @@ -213,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({ @@ -226,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"), @@ -239,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({ @@ -255,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: "", @@ -267,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({ @@ -285,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: "", @@ -297,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({ @@ -314,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: "", @@ -332,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({ @@ -433,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({ @@ -452,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(); @@ -464,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" @@ -485,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, @@ -494,7 +340,7 @@ describe("CacheHandler", () => { }, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "route", @@ -506,7 +352,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for PAGE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGE", html: "", pageData: {}, @@ -514,7 +360,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "page", @@ -526,7 +372,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for PAGES", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGES", html: "", pageData: "rsc", @@ -534,7 +380,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -547,7 +393,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"), @@ -555,7 +401,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -573,7 +419,7 @@ describe("CacheHandler", () => { ["segment2", Buffer.from("data2")], ]); - await cache.set("key", { + await instance.set("key", { kind: "APP_PAGE", html: "", rscData: Buffer.from("rsc"), @@ -583,7 +429,7 @@ describe("CacheHandler", () => { postponed: "postponed-data", }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -604,7 +450,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for FETCH", async () => { - await cache.set("key", { + await instance.set("key", { kind: "FETCH", data: { headers: {}, @@ -616,7 +462,7 @@ describe("CacheHandler", () => { revalidate: 60, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { kind: "FETCH", @@ -634,9 +480,9 @@ describe("CacheHandler", () => { }); 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", @@ -647,20 +493,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(); }); }); @@ -669,304 +515,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"); - - expect(tagCache.getByTag).toHaveBeenCalledWith("tag"); + it("Should call cache.revalidateTags with single tag", async () => { + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "/path", - tag: "tag", - }, - ]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"], undefined); }); - 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(); - }); - - it("Should only call writeTags for nextMode", async () => { - tagCache.mode = "nextMode"; - await cache.revalidateTag(["tag1", "tag2"]); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(invalidateCdnHandler.invalidatePaths).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, - }, - ], - }, - ]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], undefined); }); - }); - - 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 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`], - }); + it("Should not call cache.revalidateTags when tags array is empty", async () => { + await instance.revalidateTag([]); - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); - 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 throw when revalidateTags fails", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("Error")); - 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 35b723b4..884f75bb 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -2,59 +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 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(); @@ -67,17 +49,17 @@ describe("Composable cache handler", () => { }); 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"); @@ -85,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(), }); @@ -95,78 +77,50 @@ 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); + it("should return undefined on cache read error", async () => { + cache.get.mockRejectedValueOnce(new Error("Cache error")); 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({ + it("should set revalidate=-1 when lastModified is 1 (stale from cache adapter)", async () => { + cache.get.mockResolvedValueOnce({ value: { - type: "route", - body: "{}", - tags: [], - value: "test-value", + value: "stale-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, }, - lastModified: Date.now(), + lastModified: 1, }); - const result = await ComposableCache.get("test-key"); + const result = await ComposableCache.get("stale-key"); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); expect(result).toBeDefined(); + expect(result?.revalidate).toBe(-1); }); - it("should check last modified in original mode", async () => { - tagCache.mode = "original"; - tagCache.getLastModified.mockResolvedValueOnce(Date.now()); + it("should keep original revalidate when lastModified is not 1", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "fresh-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, + }, + lastModified: 1000, + }); - const result = await ComposableCache.get("test-key"); + const result = await ComposableCache.get("fresh-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")); - - const result = await ComposableCache.get("test-key"); - - expect(result).toBeUndefined(); + expect(result?.revalidate).toBe(3600); }); it("should return pending write promise if available", async () => { @@ -194,12 +148,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"], @@ -211,7 +160,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"], @@ -219,64 +168,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 () => { @@ -291,7 +182,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", @@ -306,131 +197,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 []; - }); - - 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) }, - ]) - ); - }); - - 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 []; - }); - + it("should call cache.revalidateTags with flat tags array", async () => { 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) }, - ]) - ); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); }); - it("should handle empty paths in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockResolvedValue([]); + it("should not call revalidateTags when no tags provided", async () => { + await ComposableCache.expireTags(); - await ComposableCache.expireTags("tag1"); - - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); @@ -439,9 +242,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(); }); }); @@ -460,7 +263,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", @@ -470,7 +273,7 @@ describe("Composable cache handler", () => { ); // Mock the get response - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { ...entry, value: "integration-test", @@ -518,8 +321,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(); @@ -531,4 +334,30 @@ describe("Composable cache handler", () => { expect(content2).toBe("concurrent-2"); }); }); + + describe("updateTags", () => { + it("should call cache.revalidateTags with tags and durations", async () => { + await ComposableCache.updateTags(["tag1", "tag2"], { expire: 30 }); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], { expire: 30 }); + }); + + it("should not call cache.revalidateTags when tags are empty", async () => { + await ComposableCache.updateTags([]); + + expect(cache.revalidateTags).not.toHaveBeenCalled(); + }); + + it("should call cache.revalidateTags without durations when not provided", async () => { + await ComposableCache.updateTags(["tag1"]); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1"], undefined); + }); + + it("should not throw on cache error", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("cache error")); + + await expect(ComposableCache.updateTags(["tag1"])).resolves.not.toThrow(); + }); + }); }); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 4b2475a7..e995f5f3 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -46,19 +46,12 @@ function createEvent(event: PartialEvent): MiddlewareEvent { }; } -const incrementalCache = { +const cache = { name: "mock", get: vi.fn(), set: vi.fn(), delete: vi.fn(), -}; - -const tagCache = { - name: "mock", - getByTag: vi.fn(), - getByPath: vi.fn(), - getLastModified: vi.fn(), - writeTags: vi.fn(), + revalidateTags: vi.fn(), }; const queue = { @@ -68,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(() => { @@ -110,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); @@ -125,7 +116,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -151,64 +142,11 @@ describe("cacheInterceptor", () => { ); }); - 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!", @@ -240,7 +178,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/revalidate", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "Hello, world!", @@ -272,7 +210,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "redirect", meta: { @@ -301,7 +239,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "?", html: "Hello, world!", @@ -318,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, @@ -358,7 +296,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "randomBinaryData"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -398,7 +336,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "API response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -440,7 +378,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "Simple response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -473,7 +411,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 403, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -489,7 +427,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 203, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -507,7 +445,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -525,7 +463,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -535,4 +473,79 @@ describe("cacheInterceptor", () => { const result = await cacheInterceptor(event); expect(result.statusCode).toBe(200); }); + + describe("isStaleFromTagCache", () => { + it("should serve SSG app content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + + it("should serve SSG page content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "page", + html: "Hello, world!", + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result.type).toBe("core"); + expect((result as any).headers["cache-control"]).toBe("s-maxage=1, stale-while-revalidate=2592000"); + expect((result as any).headers["x-opennext-cache"]).toBe("STALE"); + }); + + it("should serve SSG route content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: "API response", + meta: { + status: 200, + headers: { "content-type": "text/plain" }, + }, + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + }); }); 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..f6076ec3 --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -0,0 +1,191 @@ +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 response = { + ok: true, + status: resp.status ?? 200, + text: vi.fn().mockResolvedValue(resp.body), + headers: toHeadersMap(resp.headers), + }; + global.fetch = vi.fn().mockResolvedValue(response); +} + +describe("fetch cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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)); + }); + }); + + describe("delete", () => { + it("should make a DELETE request", async () => { + await fetchCache.delete("key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key", { method: "DELETE" }); + }); + }); + + 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"] }), + }); + }); + }); +}); 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..c458c949 --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -0,0 +1,209 @@ +import localCache from "@opennextjs/core/overrides/cache/local"; +import type { InternalResult } from "@opennextjs/core/types/open-next"; +import { 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/index.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 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(event.body).toEqual(Buffer.from(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"); + }); + }); + + 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"); + }); + }); + + 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(event.body).toEqual(Buffer.from(JSON.stringify({ tags: ["tag1", "tag2"] }))); + }); + }); + + 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..d15c65f2 --- /dev/null +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -0,0 +1,359 @@ +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 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 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(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790c7c28..cb571a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1027,12 +1027,18 @@ importers: concurrently: specifier: ^9.2.1 version: 9.2.1 + rimraf: + specifier: 'catalog:' + version: 6.1.2 tsc-alias: specifier: ^1.8.16 version: 1.8.16 typescript: specifier: 'catalog:' version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 2.1.3(@edge-runtime/vm@3.2.0)(@types/node@24.13.2)(jsdom@22.1.0)(lightningcss@1.30.2)(terser@5.16.9) packages/tests-e2e: devDependencies: