fix: nextjs cache handler for next 15 (#5717)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Matti Nannt
2025-05-09 17:51:57 +02:00
committed by GitHub
parent 53ef756723
commit 35b2356a31
13 changed files with 143 additions and 117 deletions

View File

@@ -191,8 +191,7 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -200,9 +199,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=

97
apps/web/cache-handler.js Normal file
View File

@@ -0,0 +1,97 @@
// This cache handler follows the @fortedigital/nextjs-cache-handler example
// Read more at: https://github.com/fortedigital/nextjs-cache-handler
// @neshca/cache-handler dependencies
const { CacheHandler } = require("@neshca/cache-handler");
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
// Next/Redis dependencies
const { createClient } = require("redis");
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
// @fortedigital/nextjs-cache-handler dependencies
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
// Usual onCreation from @neshca/cache-handler
CacheHandler.onCreation(() => {
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures only one instance get created
if (global.cacheHandlerConfig) {
return global.cacheHandlerConfig;
}
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures new instances are not created in a race condition
if (global.cacheHandlerConfigPromise) {
return global.cacheHandlerConfigPromise;
}
// If REDIS_URL is not set, we will use LRU cache only
if (!process.env.REDIS_URL) {
const lruCache = createLruHandler();
return { handlers: [lruCache] };
}
// Main promise initializing the handler
global.cacheHandlerConfigPromise = (async () => {
/** @type {import("redis").RedisClientType | null} */
let redisClient = null;
// eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable
if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
const settings = {
url: process.env.REDIS_URL, // Make sure you configure this variable
pingInterval: 10000,
};
try {
redisClient = createClient(settings);
redisClient.on("error", (e) => {
console.error("Redis error", e);
global.cacheHandlerConfig = null;
global.cacheHandlerConfigPromise = null;
});
} catch (error) {
console.error("Failed to create Redis client:", error);
}
}
if (redisClient) {
try {
console.info("Connecting Redis client...");
await redisClient.connect();
console.info("Redis client connected.");
} catch (error) {
console.error("Failed to connect Redis client:", error);
await redisClient
.disconnect()
.catch(() => console.error("Failed to quit the Redis client after failing to connect."));
}
}
const lruCache = createLruHandler();
if (!redisClient?.isReady) {
console.error("Failed to initialize caching layer.");
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = { handlers: [lruCache] };
return global.cacheHandlerConfig;
}
const redisCacheHandler = createRedisHandler({
client: redisClient,
keyPrefix: "nextjs:",
});
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = {
handlers: [redisCacheHandler],
};
return global.cacheHandlerConfig;
})();
return global.cacheHandlerConfigPromise;
});
module.exports = new Next15CacheHandler();

View File

@@ -1,79 +0,0 @@
import { CacheHandler } from "@neshca/cache-handler";
import createLruHandler from "@neshca/cache-handler/local-lru";
import createRedisHandler from "@neshca/cache-handler/redis-strings";
import { createClient } from "redis";
// Function to create a timeout promise
const createTimeoutPromise = (ms, rejectReason) => {
return new Promise((_, reject) => setTimeout(() => reject(new Error(rejectReason)), ms));
};
CacheHandler.onCreation(async () => {
let client;
if (process.env.REDIS_URL) {
try {
// Create a Redis client.
client = createClient({
url: process.env.REDIS_URL,
});
// Redis won't work without error handling.
client.on("error", () => {});
} catch (error) {
console.warn("Failed to create Redis client:", error);
}
if (client) {
try {
// Wait for the client to connect with a timeout of 5000ms.
const connectPromise = client.connect();
const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout
await Promise.race([connectPromise, timeoutPromise]);
} catch (error) {
console.warn("Failed to connect Redis client:", error);
console.warn("Disconnecting the Redis client...");
// Try to disconnect the client to stop it from reconnecting.
client
.disconnect()
.then(() => {
console.info("Redis client disconnected.");
})
.catch(() => {
console.warn("Failed to quit the Redis client after failing to connect.");
});
}
}
}
/** @type {import("@neshca/cache-handler").Handler | null} */
let handler;
if (client?.isReady) {
const redisHandlerOptions = {
client,
keyPrefix: "fb:",
timeoutMs: 1000,
};
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler(redisHandlerOptions);
} else {
// Fallback to LRU handler if Redis client is not available.
// The application will still work, but the cache will be in memory only and not shared.
handler = createLruHandler();
console.log("Using LRU handler for caching.");
}
return {
handlers: [handler],
ttl: {
// We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
estimateExpireAge: (staleAge) => staleAge,
},
};
});
export default CacheHandler;

View File

@@ -205,7 +205,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const REDIS_URL = env.REDIS_URL;
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
export const REDIS_DEFAULT_TTL = env.REDIS_DEFAULT_TTL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;

View File

@@ -56,7 +56,6 @@ export const env = createEnv({
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL: z.string().optional(),
REDIS_DEFAULT_TTL: z.string().optional(),
REDIS_HTTP_URL: z.string().optional(),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
@@ -163,7 +162,6 @@ export const env = createEnv({
OIDC_ISSUER: process.env.OIDC_ISSUER,
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL,
REDIS_DEFAULT_TTL: process.env.REDIS_DEFAULT_TTL,
REDIS_HTTP_URL: process.env.REDIS_HTTP_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PRIVACY_URL: process.env.PRIVACY_URL,

View File

@@ -16,6 +16,8 @@ const getHostname = (url) => {
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
cacheHandler: require.resolve("./cache-handler.js"),
cacheMaxMemorySize: 0, // disable default in-memory caching
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: false,
@@ -282,11 +284,6 @@ const nextConfig = {
},
};
// set custom cache handler
if (process.env.CUSTOM_CACHE_DISABLED !== "1") {
nextConfig.cacheHandler = require.resolve("./cache-handler.mjs");
}
// set actions allowed origins
if (process.env.WEBAPP_URL) {
nextConfig.experimental.serverActions = {
@@ -302,22 +299,25 @@ nextConfig.images.remotePatterns.push({
});
const sentryOptions = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: "formbricks",
project: "formbricks-cloud",
org: "formbricks",
project: "formbricks-cloud",
// Only print logs for uploading source maps in CI
silent: true,
// Only print logs for uploading source maps in CI
silent: true,
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
};
const exportConfig = (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
const exportConfig =
process.env.SENTRY_DSN && process.env.NODE_ENV === "production"
? withSentryConfig(nextConfig, sentryOptions)
: nextConfig;
export default exportConfig;

View File

@@ -32,6 +32,7 @@
"@formbricks/logger": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/types": "workspace:*",
"@fortedigital/nextjs-cache-handler": "1.2.0",
"@hookform/resolvers": "5.0.1",
"@intercom/messenger-js-sdk": "0.0.14",
"@json2csv/node": "7.0.6",
@@ -111,8 +112,8 @@
"posthog-js": "1.240.0",
"posthog-node": "4.17.1",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
@@ -158,8 +159,8 @@
"autoprefixer": "10.4.21",
"dotenv": "16.5.0",
"postcss": "8.5.3",
"ts-node": "10.9.2",
"resize-observer-polyfill": "1.5.1",
"ts-node": "10.9.2",
"vite": "6.3.5",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.1.3",

View File

@@ -16,13 +16,13 @@ services:
- 8025:8025 # web ui
- 1025:1025 # smtp server
redis:
image: redis:7.0.11
command: "redis-server"
valkey:
image: valkey/valkey:8.1.1
command: "valkey-server"
ports:
- 6379:6379
volumes:
- redis-data:/data
- valkey-data:/data
minio:
image: minio/minio:RELEASE.2025-02-28T09-55-16Z
@@ -31,15 +31,15 @@ services:
- MINIO_ROOT_USER=devminio
- MINIO_ROOT_PASSWORD=devminio123
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
- "9000:9000" # S3 API
- "9001:9001" # Console
volumes:
- minio-data:/data
volumes:
postgres:
driver: local
redis-data:
valkey-data:
driver: local
minio-data:
driver: local

View File

@@ -175,7 +175,6 @@ x-environment: &environment
# Set the below to use Redis for Next Caching (default is In-Memory from Next Cache)
# REDIS_URL:
# REDIS_DEFAULT_TTL:
# Set the below to use for Rate Limiting (default us In-Memory LRU Cache)
# REDIS_HTTP_URL:

View File

@@ -63,7 +63,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | |
| CUSTOM_CACHE_DISABLED | Disables custom cache handler if set to 1 (required for deployment on Vercel) | optional | |
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |

View File

@@ -126,7 +126,6 @@ Configure Redis by adding the following environment variables to your instances:
```sh env
REDIS_URL=redis://your-redis-host:6379
REDIS_DEFAULT_TTL=86400
REDIS_HTTP_URL=http://your-redis-host:8000
```

19
pnpm-lock.yaml generated
View File

@@ -151,6 +151,9 @@ importers:
'@formbricks/types':
specifier: workspace:*
version: link:../../packages/types
'@fortedigital/nextjs-cache-handler':
specifier: 1.2.0
version: 1.2.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)
'@hookform/resolvers':
specifier: 5.0.1
version: 5.0.1(react-hook-form@7.56.2(react@19.1.0))
@@ -1679,6 +1682,12 @@ packages:
'@formkit/auto-animate@0.8.2':
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
'@fortedigital/nextjs-cache-handler@1.2.0':
resolution: {integrity: sha512-dHu7+D6yVHI5ii1/DgNSZM9wVPk8uKAB0zrRoNNbZq6hggpRRwAExV4J6bSGOd26RN6ZnfYaGLBmdb0gLpeBQg==}
peerDependencies:
next: '>=13.5.1'
redis: '>=4.6'
'@gar/promisify@1.1.3':
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
@@ -11804,6 +11813,16 @@ snapshots:
'@formkit/auto-animate@0.8.2': {}
'@fortedigital/nextjs-cache-handler@1.2.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)':
dependencies:
'@neshca/cache-handler': 1.9.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(redis@4.7.0)
cluster-key-slot: 1.1.2
lru-cache: 11.1.0
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
redis: 4.7.0
optionalDependencies:
'@rollup/rollup-linux-x64-gnu': 4.40.0
'@gar/promisify@1.1.3':
optional: true

View File

@@ -77,7 +77,6 @@
"BREVO_LIST_ID",
"DOCKER_CRON_ENABLED",
"CRON_SECRET",
"CUSTOM_CACHE_DISABLED",
"DATABASE_URL",
"DEBUG",
"E2E_TESTING",
@@ -133,7 +132,6 @@
"RATE_LIMITING_DISABLED",
"REDIS_HTTP_URL",
"REDIS_URL",
"REDIS_DEFAULT_TTL",
"S3_ACCESS_KEY",
"S3_BUCKET_NAME",
"S3_ENDPOINT_URL",