From c53c88db9bf98258547e2ca512f864800821cb1f Mon Sep 17 00:00:00 2001 From: zyachel Date: Sun, 21 May 2023 18:15:03 +0530 Subject: [PATCH] feat(cache): implement caching of routes --- .env.local.example | 9 ++++-- README.md | 2 +- src/pages/api/media_proxy.ts | 50 ++++++++++++++--------------- src/pages/find/index.tsx | 24 +++++++------- src/pages/name/[nameId]/index.tsx | 4 ++- src/pages/title/[titleId]/index.tsx | 4 ++- src/utils/constants/keys.ts | 4 +++ src/utils/getOrSetApiCache.ts | 24 ++++++++++++++ src/utils/redis.ts | 3 +- 9 files changed, 82 insertions(+), 42 deletions(-) create mode 100644 src/utils/constants/keys.ts create mode 100644 src/utils/getOrSetApiCache.ts diff --git a/.env.local.example b/.env.local.example index da493b4..ecf77d9 100644 --- a/.env.local.example +++ b/.env.local.example @@ -24,10 +24,15 @@ NEXT_TELEMETRY_DISABLED=1 ################################################################################ ### 3. REDIS CONFIG(optional if you don't need redis) ################################################################################ -## if you want to use redis to speed up the media proxy, set this to true +## enables caching of api routes as well as media # USE_REDIS=true +## in case you don't want to cache media but only api routes +# USE_REDIS_FOR_API_ONLY=true +## ttl for media and api +# REDIS_CACHE_TTL_API=3600 +# REDIS_CACHE_TTL_MEDIA=3600 ## for docker, just set the domain to the container name, default is 'libremdb_redis' -REDIS_URL=localhost:6379 +# REDIS_URL=localhost:6379 ################################################################################ ### 4. INSTANCE META FIELDS(not required but good to have) diff --git a/README.md b/README.md index 8e2e223..a517801 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Inspired by projects like [teddit](https://codeberg.org/teddit/teddit), [nitter] - [ ] company info - [ ] user info -- [ ] use redis, or any other caching strategy +- [X] use redis, or any other caching strategy - [x] implement a better installation method - [x] serve images and videos from libremdb itself diff --git a/src/pages/api/media_proxy.ts b/src/pages/api/media_proxy.ts index cc6b435..56461fb 100644 --- a/src/pages/api/media_proxy.ts +++ b/src/pages/api/media_proxy.ts @@ -1,30 +1,32 @@ import { NextApiRequest, NextApiResponse } from 'next'; +import { AxiosRequestHeaders } from 'axios'; import redis from 'src/utils/redis'; import axiosInstance from 'src/utils/axiosInstance'; +import { mediaKey } from 'src/utils/constants/keys'; -const getCleanReqHeaders = (headers: NextApiRequest['headers']) => ({ - ...(headers.accept && { accept: headers.accept }), - ...(headers.range && { range: headers.range }), - ...(headers['accept-encoding'] && { - 'accept-encoding': headers['accept-encoding'] as string, - }), -}); +const dontCacheMedia = + process.env.USE_REDIS_FOR_API_ONLY === 'true' || process.env.USE_REDIS !== 'true'; -const resHeadersArr = [ - 'content-range', - 'content-length', - 'content-type', - 'accept-ranges', -]; +const ttl = process.env.REDIS_CACHE_TTL_MEDIA ?? 30 * 60; + +const getCleanReqHeaders = (headers: NextApiRequest['headers']) => { + const cleanHeaders: AxiosRequestHeaders = {}; + + if (headers.accept) cleanHeaders.accept = headers.accept; + if (headers.range) cleanHeaders.range = headers.range; + if (headers['accept-encoding']) + cleanHeaders['accept-encoding'] = headers['accept-encoding'].toString(); + + return cleanHeaders; +}; + +const resHeadersArr = ['content-range', 'content-length', 'content-type', 'accept-ranges']; // checks if a url is pointing towards a video/image from imdb const regex = /^https:\/\/((m\.)?media-amazon\.com|imdb-video\.media-imdb\.com).*\.(jpg|jpeg|png|mp4|gif|webp).*$/; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { +export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { const mediaUrl = req.query.url as string | undefined; const requestHeaders = getCleanReqHeaders(req.headers); @@ -36,8 +38,8 @@ export default async function handler( message: 'Invalid query', }); - // 2. sending streamed response if redis isn't enabled - if (redis === null) { + // 2. sending streamed response if redis, or redis for media isn't enabled + if (dontCacheMedia) { const mediaRes = await axiosInstance.get(mediaUrl, { responseType: 'stream', headers: requestHeaders, @@ -54,23 +56,21 @@ export default async function handler( } // 3. else if resourced is cached, sending it - const cachedMedia = await redis!.getBuffer(mediaUrl); + const cachedMedia = await redis.getBuffer(mediaKey(mediaUrl)); if (cachedMedia) { res.setHeader('x-cached', 'true'); - res.status(302).send(cachedMedia); + res.status(304).send(cachedMedia); return; } // 4. else getting, caching and sending response - const mediaRes = await axiosInstance(mediaUrl, { + const { data } = await axiosInstance(mediaUrl, { responseType: 'arraybuffer', }); - const { data } = mediaRes; - // saving in redis for 30 minutes - await redis!.setex(mediaUrl, 30 * 60, Buffer.from(data)); + await redis.setex(mediaKey(mediaUrl), ttl, Buffer.from(data)); // sending media res.setHeader('x-cached', 'false'); diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx index 3f60f3d..7bf325a 100644 --- a/src/pages/find/index.tsx +++ b/src/pages/find/index.tsx @@ -1,4 +1,4 @@ -import { GetServerSideProps } from 'next'; +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; import Layout from 'src/layouts/Layout'; import ErrorInfo from 'src/components/error/ErrorInfo'; import Meta from 'src/components/meta/Meta'; @@ -7,13 +7,12 @@ import Form from 'src/components/forms/find'; import Find, { FindQueryParams } from 'src/interfaces/shared/search'; import { AppError } from 'src/interfaces/shared/error'; import basicSearch from 'src/utils/fetchers/basicSearch'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; import { cleanQueryStr } from 'src/utils/helpers'; +import { findKey } from 'src/utils/constants/keys'; import styles from 'src/styles/modules/pages/find/find.module.scss'; -type Props = - | { data: { title: string; results: Find }; error: null } - | { data: { title: null; results: null }; error: null } - | { data: { title: string; results: null }; error: AppError }; +type Props = InferGetServerSidePropsType; const getMetadata = (title: string | null) => ({ title: title || 'Search', @@ -23,8 +22,7 @@ const getMetadata = (title: string | null) => ({ }); const BasicSearch = ({ data: { title, results }, error }: Props) => { - if (error) - return ; + if (error) return ; return ( <> @@ -40,19 +38,23 @@ const BasicSearch = ({ data: { title, results }, error }: Props) => { }; // TODO: use generics for passing in queryParams(to components) for better type-checking. -export const getServerSideProps: GetServerSideProps = async ctx => { +type Data = + | { data: { title: string; results: Find }; error: null } + | { data: { title: null; results: null }; error: null } + | { data: { title: string; results: null }; error: AppError }; + +export const getServerSideProps: GetServerSideProps = async ctx => { // sample query str: find/?q=babylon&s=tt&ttype=ft&exact=true const queryObj = ctx.query as FindQueryParams; const query = queryObj.q?.trim(); - if (!query) - return { props: { data: { title: null, results: null }, error: null } }; + if (!query) return { props: { data: { title: null, results: null }, error: null } }; try { const entries = Object.entries(queryObj); const queryStr = cleanQueryStr(entries); - const res = await basicSearch(queryStr); + const res = await getOrSetApiCache(findKey(queryStr), basicSearch, queryStr); return { props: { data: { title: query, results: res }, error: null }, diff --git a/src/pages/name/[nameId]/index.tsx b/src/pages/name/[nameId]/index.tsx index e5a6d07..51c35d3 100644 --- a/src/pages/name/[nameId]/index.tsx +++ b/src/pages/name/[nameId]/index.tsx @@ -7,7 +7,9 @@ import { Basic, Credits, DidYouKnow, Info, Bio, KnownFor } from 'src/components/ import Name from 'src/interfaces/shared/name'; import { AppError } from 'src/interfaces/shared/error'; import name from 'src/utils/fetchers/name'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; import { getProxiedIMDbImgUrl } from 'src/utils/helpers'; +import { nameKey } from 'src/utils/constants/keys'; import styles from 'src/styles/modules/pages/name/name.module.scss'; type Props = InferGetServerSidePropsType; @@ -46,7 +48,7 @@ export const getServerSideProps: GetServerSideProps = async ctx => const nameId = ctx.params!.nameId; try { - const data = await name(nameId); + const data = await getOrSetApiCache(nameKey(nameId), name, nameId); return { props: { data, error: null } }; } catch (error: any) { diff --git a/src/pages/title/[titleId]/index.tsx b/src/pages/title/[titleId]/index.tsx index 38325ad..fb27c5e 100644 --- a/src/pages/title/[titleId]/index.tsx +++ b/src/pages/title/[titleId]/index.tsx @@ -6,8 +6,10 @@ import Media from 'src/components/media/Media'; import { Basic, Cast, DidYouKnow, Info, MoreLikeThis, Reviews } from 'src/components/title'; import Title from 'src/interfaces/shared/title'; import { AppError } from 'src/interfaces/shared/error'; +import getOrSetApiCache from 'src/utils/getOrSetApiCache'; import title from 'src/utils/fetchers/title'; import { getProxiedIMDbImgUrl } from 'src/utils/helpers'; +import { titleKey } from 'src/utils/constants/keys'; import styles from 'src/styles/modules/pages/title/title.module.scss'; type Props = InferGetServerSidePropsType; @@ -55,7 +57,7 @@ export const getServerSideProps: GetServerSideProps = async ctx => const titleId = ctx.params!.titleId; try { - const data = await title(titleId); + const data = await getOrSetApiCache(titleKey(titleId), title, titleId); return { props: { data, error: null } }; } catch (error: any) { diff --git a/src/utils/constants/keys.ts b/src/utils/constants/keys.ts new file mode 100644 index 0000000..aceaefd --- /dev/null +++ b/src/utils/constants/keys.ts @@ -0,0 +1,4 @@ +export const titleKey = (titleId: string) => `title:${titleId}`; +export const nameKey = (nameId: string) => `name:${nameId}`; +export const findKey = (query: string) => `find:${query}`; +export const mediaKey = (url: string) => `media:${url}`; diff --git a/src/utils/getOrSetApiCache.ts b/src/utils/getOrSetApiCache.ts new file mode 100644 index 0000000..6275edd --- /dev/null +++ b/src/utils/getOrSetApiCache.ts @@ -0,0 +1,24 @@ +import redis from 'src/utils/redis'; + +const ttl = process.env.REDIS_CACHE_TTL_API ?? 30 * 60; +const redisEnabled = + process.env.USE_REDIS === 'true' || process.env.USE_REDIS_FOR_API_ONLY === 'true'; + +const getOrSetApiCache = async Promise>( + key: string, + fetcher: T, + ...fetcherArgs: Parameters +): Promise> => { + if (!redisEnabled) return await fetcher(...fetcherArgs); + + const dataInCache = await redis.get(key); + if (dataInCache) return JSON.parse(dataInCache); + + const dataToCache = await fetcher(...fetcherArgs); + + await redis.setex(key, ttl, JSON.stringify(dataToCache)); + + return dataToCache; +}; + +export default getOrSetApiCache; diff --git a/src/utils/redis.ts b/src/utils/redis.ts index a517273..f5fe3a4 100644 --- a/src/utils/redis.ts +++ b/src/utils/redis.ts @@ -2,7 +2,8 @@ import Redis from 'ioredis'; const redisUrl = process.env.REDIS_URL; -const toUseRedis = process.env.USE_REDIS === 'true'; +const toUseRedis = + process.env.USE_REDIS === 'true' || process.env.USE_REDIS_FOR_API_ONLY === 'true'; const stub: Pick = { get: async key => Promise.resolve(null),