diff --git a/.dockerignore b/.dockerignore index 7bfa853d..1fca89f2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,6 @@ .env.local .github .git +docker/local.env +docker/sandbox.env node_modules \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 889e260f..5d5d8aef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ ENV NEXT_PUBLIC_FE_URL="NP--FE--URL" ENV NEXT_PUBLIC_ADMIN_URL="NP--ADMIN--URL" ENV NEXT_PUBLIC_API_URL="NP--API--URL" ENV NEXT_PUBLIC_TUS_URL="NP--TUS--URL" +ENV REDIS_CACHE="enabled" COPY --from=deps /app/node_modules ./node_modules COPY . ./ diff --git a/README.md b/README.md index 020faceb..fa88cd37 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# NGLP Frontend +# Meru Frontend ## Development Environment ```sh yarn install -yarn run dev # to start dev client +yarn dev -p 3001 # to start dev client ``` That's it! Now go to http://localhost:3001/ @@ -40,6 +40,38 @@ Header.tsx // Defines the component styles.ts // Styled components ``` +### Cache Testing + +`meru-frontend` is designed to use Valkey for caching when running in production. To test this, you may also run it locally with the provided docker-compose.yml and some scripts in `bin`. + +First, ensure that Valkey is running locally on port 36379: + +```bash +docker compose up -d +``` + +Then, build a local version of the image. You will need to do this on any change to the application code. Since we need to test how it works in production, there's no live-reloading of changes. Once it has been built, you can run it against one of two versions of the API with some scripts. + +```bash +bin/build-local-image +``` + +To run against the local API, which supports testing revalidation: + +```bash +bin/run-local-against-local-api +``` + +This will be available on [localhost:14700](http://localhost:14700). + +To run against the Sandbox API, which _does not_ support testing revalidation: + +```bash +bin/run-local-against-sandbox +``` + +This will be available on [localhost:14800](http://localhost:14800). + ### Browser support - Edge (Chromium latest) diff --git a/app/[frontend]/(pages)/layout.tsx b/app/[frontend]/(pages)/layout.tsx index a5cb159d..38ae3843 100644 --- a/app/[frontend]/(pages)/layout.tsx +++ b/app/[frontend]/(pages)/layout.tsx @@ -15,7 +15,7 @@ import { BasePageParams } from "@/types/page"; import ProgressBar from "@/components/atomic/loading/ProgressBar"; import generateSiteMetadata from "./_metadata/site"; -export const revalidate = 0; +export const revalidate = 43200; export async function generateMetadata( props: BasePageParams, diff --git a/app/[frontend]/layout.tsx b/app/[frontend]/layout.tsx index 370ec745..a13ed0cf 100644 --- a/app/[frontend]/layout.tsx +++ b/app/[frontend]/layout.tsx @@ -11,7 +11,7 @@ import { layoutThemeQuery as Query } from "@/relay/layoutThemeQuery.graphql"; import { getThemeClasses } from "@/styles/theme-helpers"; import type { PropsWithChildren } from "react"; -export const revalidate = 0; +export const revalidate = 43200; export default async function RootLayout({ children }: PropsWithChildren) { const { data } = await fetchQuery(query, {}); diff --git a/bin/build-local-image b/bin/build-local-image new file mode 100755 index 00000000..ae3269cd --- /dev/null +++ b/bin/build-local-image @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# This image will build a local version of the meru-frontend image +# that can be used for local development and testing. +# You can actually run it with the other scripts in this directory. + +docker buildx build --platform linux/amd64 -t meru/meru-frontend-local . \ No newline at end of file diff --git a/bin/run-local-against-local-api b/bin/run-local-against-local-api new file mode 100755 index 00000000..a93625c6 --- /dev/null +++ b/bin/run-local-against-local-api @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# The purpose of this script is to run a locally-built docker +# image of the meru-frontend against a locally-running meru API. +# Make sure that you have built the local version of the frontend +# with `bin/build-local-image`. + +# Ensure valkey is running +docker compose up -d + +docker run -it --rm -p 14700:3000 -w /app \ + --env-file docker/local.env \ + meru/meru-frontend-local \ No newline at end of file diff --git a/bin/run-local-against-sandbox b/bin/run-local-against-sandbox new file mode 100755 index 00000000..3366812b --- /dev/null +++ b/bin/run-local-against-sandbox @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# The purpose of this script is to run a locally-built docker +# image of the meru-frontend against the sandbox version of the meru API. +# Make sure that you have built the local version of the frontend +# with `bin/build-local-image`. + +# Ensure valkey is running +docker compose up -d + +docker run -it --rm -p 14800:3000 -w /app \ + --env-file docker/sandbox.env \ + meru/meru-frontend-local \ No newline at end of file diff --git a/customized-cache-handler.cjs b/customized-cache-handler.cjs new file mode 100644 index 00000000..735e6ca0 --- /dev/null +++ b/customized-cache-handler.cjs @@ -0,0 +1,64 @@ +const { RedisStringsHandler } = require("@trieb.work/nextjs-turbo-redis-cache"); + +// Singleton pattern to ensure only one instance of RedisStringsHandler +let cachedHandler; + +function parseEnvInt(value, defaultValue) { + const parsed = parseInt(value, 10); + + return isNaN(parsed) ? defaultValue : parsed; +} + +function socketOptionsFor(redisURL) { + if (!redisURL || !redisURL.startsWith('rediss://')) { + return null; + } + + return { + tls: true, + rejectUnauthorized: false, + }; +} + +const REDIS_DB = parseEnvInt(process.env.REDIS_DB, 1); + +module.exports = class CustomizedCacheHandler { + constructor() { + // We need this class to be available during production / docker builds, + // but we only want to connect to Redis when actually running the server. + if (!cachedHandler && process.env.REDIS_URL) { + const socketOptions = socketOptionsFor(process.env.REDIS_URL); + + cachedHandler = new RedisStringsHandler({ + // https://github.com/trieb-work/nextjs-turbo-redis-cache?tab=readme-ov-file#available-options + database: REDIS_DB, + keyPrefix: 'meru-client:', + timeoutMs: 2_000, + revalidateTagQuerySize: 500, + sharedTagsKey: '__sharedTags__', + avgResyncIntervalMs: 10_000 * 60, + redisGetDeduplication: false, + inMemoryCachingTime: 3000, + defaultStaleAge: 1209600, + estimateExpireAge: (staleAge) => staleAge * 2, + socketOptions, + }); + } + } + + get(...args) { + return cachedHandler?.get(...args) ?? null; + } + + set(...args) { + return cachedHandler?.set(...args) ?? null; + } + + revalidateTag(...args) { + return cachedHandler?.revalidateTag(...args) ?? null; + } + + resetRequestCache(...args) { + return cachedHandler?.resetRequestCache(...args) ?? null; + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..ee1bd65a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + redis: + image: valkey/valkey:8.0 + environment: + - "ALLOW_EMPTY_PASSWORD=yes" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "10" + restart: unless-stopped + ports: + - "36379:6379" + volumes: + - ./docker/valkey:/usr/local/etc/valkey + command: ["valkey-server", "/usr/local/etc/valkey/valkey.conf"] + healthcheck: + test: ["CMD", "valkey-cli", "--raw", "incr", "ping"] + interval: 30s + timeout: 5s + retries: 5 diff --git a/docker/local.env b/docker/local.env new file mode 100644 index 00000000..fa4077a3 --- /dev/null +++ b/docker/local.env @@ -0,0 +1,26 @@ +AUTH_SECRET=12345secret +NODE_ENV=production + +NEXT_PUBLIC_API_URL=http://host.docker.internal:6222/graphql +NEXT_PUBLIC_TUS_URL=http://host.docker.internal:6222/files + +# Keycloak +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=meru-public +NEXT_KEYCLOAK_CLIENT_SECRET=12345secret +NEXT_PUBLIC_KEYCLOAK_REALM=meru +NEXT_PUBLIC_KEYCLOAK_URL=https://id.sandbox.meru.host + +# Secret token for revalidation requests +REVALIDATE_SECRET=revalidatenow + +REDIS_URL=redis://host.docker.internal:36379 +REDIS_DB=2 + +# Other Next.js vars +NEXT_PUBLIC_FE_URL=http://localhost:14700 +NEXT_PUBLIC_VERSION=1.0.0 +NEXT_PUBLIC_ORDER_PATH_OPTIONS="props.volume.sortable_number,props.sortable_number,props.id" + +# Optional +NEXT_PUBLIC_SITEMAP_CACHE_MAXAGE=86400 +NEXT_PUBLIC_SITEMAP_CACHE_REVALIDATE=59 \ No newline at end of file diff --git a/docker/sandbox.env b/docker/sandbox.env new file mode 100644 index 00000000..4f6fa792 --- /dev/null +++ b/docker/sandbox.env @@ -0,0 +1,26 @@ +AUTH_SECRET=12345secret +NODE_ENV=production + +NEXT_PUBLIC_API_URL=https://api.sandbox.meru.host/graphql +NEXT_PUBLIC_TUS_URL=https://api.sandbox.meru.host/files + +# Keycloak +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=meru-public +NEXT_KEYCLOAK_CLIENT_SECRET=12345secret +NEXT_PUBLIC_KEYCLOAK_REALM=meru +NEXT_PUBLIC_KEYCLOAK_URL=https://id.sandbox.meru.host + +# Secret token for revalidation requests +REVALIDATE_SECRET=revalidatenow + +REDIS_URL=redis://host.docker.internal:36379 +REDIS_DB=3 + +# Other Next.js vars +NEXT_PUBLIC_FE_URL=http://localhost:14800 +NEXT_PUBLIC_VERSION=1.0.0 +NEXT_PUBLIC_ORDER_PATH_OPTIONS="props.volume.sortable_number,props.sortable_number,props.id" + +# Optional +NEXT_PUBLIC_SITEMAP_CACHE_MAXAGE=86400 +NEXT_PUBLIC_SITEMAP_CACHE_REVALIDATE=59 \ No newline at end of file diff --git a/docker/valkey/valkey.conf b/docker/valkey/valkey.conf new file mode 100644 index 00000000..a93fab36 --- /dev/null +++ b/docker/valkey/valkey.conf @@ -0,0 +1,3 @@ +maxmemory-policy allkeys-lru + +notify-keyspace-events Exe \ No newline at end of file diff --git a/next.config.js b/next.config.js index 74175c00..7596d1e9 100644 --- a/next.config.js +++ b/next.config.js @@ -105,6 +105,11 @@ const nextConfig = { }, }; +if (process.env.NODE_ENV === "production" || process.env.REDIS_CACHE === "enabled") { + nextConfig.cacheHandler = require.resolve("./customized-cache-handler.cjs"); + nextConfig.cacheMaxMemorySize = 0; +} + const withMDX = createMDX(); module.exports = withMDX(nextConfig); diff --git a/package.json b/package.json index 6bb0feab..16126b5e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@mdx-js/mdx": "^3.1.0", "@next/mdx": "^15.0.0", "@spissvinkel/alea": "^1.2.1", + "@trieb.work/nextjs-turbo-redis-cache": "^1.8.1", "@types/mdx": "^2.0.13", "chunk-text": "^2.0.1", "classnames": "^2.5.1", @@ -44,6 +45,7 @@ "react-pdf": "^10.1.0", "react-relay": "^16.2.0", "reakit": "^1.3.11", + "redis": "4.7.0", "regenerator-runtime": "^0.14.1", "rehype-raw": "^7.0.0", "relay-compiler": "^16.2.0", diff --git a/yarn.lock b/yarn.lock index 9c214959..8988f487 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2215,6 +2215,62 @@ __metadata: languageName: node linkType: hard +"@redis/bloom@npm:1.2.0": + version: 1.2.0 + resolution: "@redis/bloom@npm:1.2.0" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/7dde8e67188164e96226c8a5c78ebd2801f1662947371e78fb95fb180c1e9ddff8d237012eb5e9182775be61cb546f67f759927cdaee0d178d863ee290e1fb27 + languageName: node + linkType: hard + +"@redis/client@npm:1.6.0": + version: 1.6.0 + resolution: "@redis/client@npm:1.6.0" + dependencies: + cluster-key-slot: "npm:1.1.2" + generic-pool: "npm:3.9.0" + yallist: "npm:4.0.0" + checksum: 10c0/c80a01b4f72d32284515dac6d1aefe0e9c881d08b8db33281f87b51650c1c116b18074a29ca81599d15dccb37b29eef9b26a75a5755150ae27d163e680c34bf6 + languageName: node + linkType: hard + +"@redis/graph@npm:1.1.1": + version: 1.1.1 + resolution: "@redis/graph@npm:1.1.1" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/64199db2cb3669c4911af8aba3b7116c4c2c1df37ca74b2a65555e62c863935a0cea74bc41bd92acf2e551074eb2a30c75f54a9f439b40e0f9bb67fc5fb66614 + languageName: node + linkType: hard + +"@redis/json@npm:1.0.7": + version: 1.0.7 + resolution: "@redis/json@npm:1.0.7" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/cef473711d66f7568a16edbd728acca7d237cfeaa15e0326b5b628dfab4afc0c76c7354e7f8efad6ecc64a1cb774e4aa060ee46497b633e18ba0a2f0aace1cc4 + languageName: node + linkType: hard + +"@redis/search@npm:1.2.0": + version: 1.2.0 + resolution: "@redis/search@npm:1.2.0" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/01d57ac10d2c5698e04e4a2f945440db3087e8834643ca950c099879dbcd77526604ca6f5c2ee883dfd4b337b0a24cb7d81ac56845aa83f89a4f161362a08dc6 + languageName: node + linkType: hard + +"@redis/time-series@npm:1.1.0": + version: 1.1.0 + resolution: "@redis/time-series@npm:1.1.0" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/503d0d5cbc9113d26666bb7b4dea57619badbcdfeee0369abf647250f26c5482ed5827c83f88f9f0cf22e021e3e7cb562459669d733fac05652972e208d6ba0f + languageName: node + linkType: hard + "@repeaterjs/repeater@npm:^3.0.4, @repeaterjs/repeater@npm:^3.0.6": version: 3.0.6 resolution: "@repeaterjs/repeater@npm:3.0.6" @@ -2269,6 +2325,16 @@ __metadata: languageName: node linkType: hard +"@trieb.work/nextjs-turbo-redis-cache@npm:^1.8.1": + version: 1.8.1 + resolution: "@trieb.work/nextjs-turbo-redis-cache@npm:1.8.1" + peerDependencies: + next: ">=15.0.3 <= 15.3.2" + redis: 4.7.0 + checksum: 10c0/cfa1700768eb4ae3da7e17ce05352261669fd72974645817b059a7c40da0a1342aaf3820bff31c86f4f913db7e76a57ef30ec2024e1e13f38eb4a21f57445ab9 + languageName: node + linkType: hard + "@types/chunk-text@npm:^1.0.2": version: 1.0.2 resolution: "@types/chunk-text@npm:1.0.2" @@ -3430,6 +3496,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:1.1.2": + version: 1.1.2 + resolution: "cluster-key-slot@npm:1.1.2" + checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 + languageName: node + linkType: hard + "collapse-white-space@npm:^2.0.0": version: 2.1.0 resolution: "collapse-white-space@npm:2.1.0" @@ -4836,6 +4909,13 @@ __metadata: languageName: node linkType: hard +"generic-pool@npm:3.9.0": + version: 3.9.0 + resolution: "generic-pool@npm:3.9.0" + checksum: 10c0/6b314d0d71170d5cbaf7162c423f53f8d6556b2135626a65bcdc03c089840b0a2f59eeb2d907939b8200e945eaf71ceb6630426f22d2128a1d242aec4b232aa7 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -6592,6 +6672,7 @@ __metadata: "@next/eslint-plugin-next": "npm:^15.5.3" "@next/mdx": "npm:^15.0.0" "@spissvinkel/alea": "npm:^1.2.1" + "@trieb.work/nextjs-turbo-redis-cache": "npm:^1.8.1" "@types/chunk-text": "npm:^1.0.2" "@types/lodash": "npm:^4.14.202" "@types/mdx": "npm:^2.0.13" @@ -6643,6 +6724,7 @@ __metadata: react-pdf: "npm:^10.1.0" react-relay: "npm:^16.2.0" reakit: "npm:^1.3.11" + redis: "npm:4.7.0" regenerator-runtime: "npm:^0.14.1" rehype-raw: "npm:^7.0.0" relay-compiler: "npm:^16.2.0" @@ -8405,6 +8487,20 @@ __metadata: languageName: node linkType: hard +"redis@npm:4.7.0": + version: 4.7.0 + resolution: "redis@npm:4.7.0" + dependencies: + "@redis/bloom": "npm:1.2.0" + "@redis/client": "npm:1.6.0" + "@redis/graph": "npm:1.1.1" + "@redis/json": "npm:1.0.7" + "@redis/search": "npm:1.2.0" + "@redis/time-series": "npm:1.1.0" + checksum: 10c0/a05632a58adbcaa4566238073cd6d00ed008522d2ef015a31aaef200c184a4eff4fa007c514eda91dda1e1205350b5901d0c7b58824dbfa593feb81a0087bf4d + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -10239,6 +10335,13 @@ __metadata: languageName: node linkType: hard +"yallist@npm:4.0.0, yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -10246,13 +10349,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^4.0.0": - version: 4.0.0 - resolution: "yallist@npm:4.0.0" - checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a - languageName: node - linkType: hard - "yallist@npm:^5.0.0": version: 5.0.0 resolution: "yallist@npm:5.0.0"