From 35b2356a311b738b35d875e61be61efdf1ee399a Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Fri, 9 May 2025 17:51:57 +0200 Subject: [PATCH] fix: nextjs cache handler for next 15 (#5717) Co-authored-by: Piyush Gupta --- .env.example | 6 +- apps/web/cache-handler.js | 97 +++++++++++++++++++ apps/web/cache-handler.mjs | 79 --------------- apps/web/lib/constants.ts | 1 - apps/web/lib/env.ts | 2 - apps/web/next.config.mjs | 32 +++--- apps/web/package.json | 5 +- docker-compose.dev.yml | 14 +-- docker/docker-compose.yml | 1 - .../configuration/environment-variables.mdx | 1 - docs/self-hosting/setup/cluster-setup.mdx | 1 - pnpm-lock.yaml | 19 ++++ turbo.json | 2 - 13 files changed, 143 insertions(+), 117 deletions(-) create mode 100644 apps/web/cache-handler.js delete mode 100644 apps/web/cache-handler.mjs diff --git a/.env.example b/.env.example index 11b4efd35a..1e85f10ea2 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/web/cache-handler.js b/apps/web/cache-handler.js new file mode 100644 index 0000000000..9a8ecdd59c --- /dev/null +++ b/apps/web/cache-handler.js @@ -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(); diff --git a/apps/web/cache-handler.mjs b/apps/web/cache-handler.mjs deleted file mode 100644 index 1065fa3b83..0000000000 --- a/apps/web/cache-handler.mjs +++ /dev/null @@ -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; diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index ecae3f2373..7c1877f808 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -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; diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 42428335b1..c7dc1fd3af 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -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, diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 85aa8c9acb..ddc3bfc9df 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -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; diff --git a/apps/web/package.json b/apps/web/package.json index 3ed9ef60dd..5d1d7a6796 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3cb2e407b5..163cf4a68f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6d67334e65..6c19162bf3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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: diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx index bbd6517b6f..a2e62b037e 100644 --- a/docs/self-hosting/configuration/environment-variables.mdx +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -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 | diff --git a/docs/self-hosting/setup/cluster-setup.mdx b/docs/self-hosting/setup/cluster-setup.mdx index 84d1f7e745..73d4b6244d 100644 --- a/docs/self-hosting/setup/cluster-setup.mdx +++ b/docs/self-hosting/setup/cluster-setup.mdx @@ -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 ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3cf8a7c5d..e7f5ce6886 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/turbo.json b/turbo.json index e914df5c54..c0b51c6477 100644 --- a/turbo.json +++ b/turbo.json @@ -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",