redis cache

This commit is contained in:
Igor Loskutov
2025-09-04 10:33:12 -04:00
parent 23a119dc3b
commit 40fe4c1bc7
8 changed files with 2706 additions and 27 deletions

View File

@@ -0,0 +1,85 @@
import {
getTokenCache,
setTokenCache,
deleteTokenCache,
TokenCacheEntry,
KV,
} from "../redisTokenCache";
const mockKV: KV & {
clear: () => void;
} = (() => {
const data = new Map<string, string>();
return {
async get(key: string): Promise<string | null> {
return data.get(key) || null;
},
async setex(key: string, seconds_: number, value: string): Promise<"OK"> {
data.set(key, value);
return "OK";
},
async del(key: string): Promise<number> {
const existed = data.has(key);
data.delete(key);
return existed ? 1 : 0;
},
clear() {
data.clear();
},
};
})();
describe("Redis Token Cache", () => {
beforeEach(() => {
mockKV.clear();
});
test("basic write/read - value written equals value read", async () => {
const testKey = "token:test-user-123";
const testValue: TokenCacheEntry = {
token: {
sub: "test-user-123",
name: "Test User",
email: "test@example.com",
accessToken: "access-token-123",
accessTokenExpires: Date.now() + 3600000, // 1 hour from now
refreshToken: "refresh-token-456",
},
timestamp: Date.now(),
};
await setTokenCache(mockKV, testKey, testValue);
const retrievedValue = await getTokenCache(mockKV, testKey);
expect(retrievedValue).not.toBeNull();
expect(retrievedValue).toEqual(testValue);
expect(retrievedValue?.token.accessToken).toBe(testValue.token.accessToken);
expect(retrievedValue?.token.sub).toBe(testValue.token.sub);
expect(retrievedValue?.timestamp).toBe(testValue.timestamp);
});
test("get returns null for non-existent key", async () => {
const result = await getTokenCache(mockKV, "non-existent-key");
expect(result).toBeNull();
});
test("delete removes token from cache", async () => {
const testKey = "token:delete-test";
const testValue: TokenCacheEntry = {
token: {
accessToken: "test-token",
accessTokenExpires: Date.now() + 3600000,
},
timestamp: Date.now(),
};
await setTokenCache(mockKV, testKey, testValue);
await deleteTokenCache(mockKV, testKey);
const result = await getTokenCache(mockKV, testKey);
expect(result).toBeNull();
});
});

View File

@@ -11,12 +11,13 @@ import {
REFRESH_ACCESS_TOKEN_BEFORE, REFRESH_ACCESS_TOKEN_BEFORE,
REFRESH_ACCESS_TOKEN_ERROR, REFRESH_ACCESS_TOKEN_ERROR,
} from "./auth"; } from "./auth";
import {
getTokenCache,
setTokenCache,
deleteTokenCache,
} from "./redisTokenCache";
import { tokenCacheRedis } from "./redisClient";
// TODO redis for vercel?
const tokenCache = new Map<
string,
{ token: JWTWithAccessToken; timestamp: number }
>();
// REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days) // REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days)
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
@@ -55,7 +56,7 @@ export const authOptions: AuthOptions = {
const expiresAtS = assertExists(account.expires_at); const expiresAtS = assertExists(account.expires_at);
const expiresAtMs = expiresAtS * 1000; const expiresAtMs = expiresAtS * 1000;
if (!account.access_token) { if (!account.access_token) {
tokenCache.delete(KEY); await deleteTokenCache(tokenCacheRedis, KEY);
} else { } else {
const jwtToken: JWTWithAccessToken = { const jwtToken: JWTWithAccessToken = {
...token, ...token,
@@ -63,8 +64,7 @@ export const authOptions: AuthOptions = {
accessTokenExpires: expiresAtMs, accessTokenExpires: expiresAtMs,
refreshToken: account.refresh_token, refreshToken: account.refresh_token,
}; };
// Store in memory cache await setTokenCache(tokenCacheRedis, KEY, {
tokenCache.set(KEY, {
token: jwtToken, token: jwtToken,
timestamp: Date.now(), timestamp: Date.now(),
}); });
@@ -72,7 +72,7 @@ export const authOptions: AuthOptions = {
} }
} }
const currentToken = tokenCache.get(KEY); const currentToken = await getTokenCache(tokenCacheRedis, KEY);
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) { if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
return currentToken.token; return currentToken.token;
} }
@@ -109,10 +109,10 @@ async function lockedRefreshAccessToken(
const refreshPromise = (async () => { const refreshPromise = (async () => {
try { try {
const cached = tokenCache.get(`token:${token.sub}`); const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`);
if (cached) { if (cached) {
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) { if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
tokenCache.delete(`token:${token.sub}`); await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
} else if (Date.now() < cached.token.accessTokenExpires) { } else if (Date.now() < cached.token.accessTokenExpires) {
return cached.token; return cached.token;
} }
@@ -121,7 +121,7 @@ async function lockedRefreshAccessToken(
const currentToken = cached?.token || (token as JWTWithAccessToken); const currentToken = cached?.token || (token as JWTWithAccessToken);
const newToken = await refreshAccessToken(currentToken); const newToken = await refreshAccessToken(currentToken);
tokenCache.set(`token:${token.sub}`, { await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
token: newToken, token: newToken,
timestamp: Date.now(), timestamp: Date.now(),
}); });

2
www/app/lib/next.ts Normal file
View File

@@ -0,0 +1,2 @@
// next.js tries to run all the lib code during build phase; we don't always want it when e.g. we have connections initialized we don't want to have
export const isBuildPhase = process.env.NEXT_PHASE?.includes("build");

View File

@@ -0,0 +1,46 @@
import Redis from "ioredis";
import { isBuildPhase } from "./next";
export type RedisClient = Pick<Redis, "get" | "setex" | "del">;
const getRedisClient = (): RedisClient => {
const redisUrl = process.env.KV_URL;
if (!redisUrl) {
throw new Error("KV_URL environment variable is required");
}
const redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
redis.on("error", (error) => {
console.error("Redis error:", error);
});
// not necessary but will indicate redis config errors by failfast at startup
// happens only once; after that connection is allowed to die and the lib is assumed to be able to restore it eventually
redis.connect().catch((e) => {
console.error("Failed to connect to Redis:", e);
process.exit(1);
});
return redis;
};
// next.js buildtime usage - we want to isolate next.js "build" time concepts here
const noopClient: RedisClient = (() => {
const noopSetex: Redis["setex"] = async () => {
return "OK" as const;
};
const noopDel: Redis["del"] = async () => {
return 0;
};
return {
get: async () => {
return null;
},
setex: noopSetex,
del: noopDel,
};
})();
export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient();

View File

@@ -0,0 +1,61 @@
import { z } from "zod";
import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
const TokenCacheEntrySchema = z.object({
token: z.object({
sub: z.string().optional(),
name: z.string().nullish(),
email: z.string().nullish(),
accessToken: z.string(),
accessTokenExpires: z.number(),
refreshToken: z.string().optional(),
error: z.string().optional(),
}),
timestamp: z.number(),
});
const TokenCacheEntryCodec = z.codec(z.string(), TokenCacheEntrySchema, {
decode: (jsonString) => {
const parsed = JSON.parse(jsonString);
return TokenCacheEntrySchema.parse(parsed);
},
encode: (value) => JSON.stringify(value),
});
export type TokenCacheEntry = z.infer<typeof TokenCacheEntrySchema>;
export type KV = {
get(key: string): Promise<string | null>;
setex(key: string, seconds: number, value: string): Promise<"OK">;
del(key: string): Promise<number>;
};
export async function getTokenCache(
redis: KV,
key: string,
): Promise<TokenCacheEntry | null> {
const data = await redis.get(key);
if (!data) return null;
try {
return TokenCacheEntryCodec.decode(data);
} catch (error) {
console.error("Invalid token cache data:", error);
await redis.del(key);
return null;
}
}
export async function setTokenCache(
redis: KV,
key: string,
value: TokenCacheEntry,
): Promise<void> {
const encodedValue = TokenCacheEntryCodec.encode(value);
const ttlSeconds = Math.floor(REFRESH_ACCESS_TOKEN_BEFORE / 1000);
await redis.setex(key, ttlSeconds, encodedValue);
}
export async function deleteTokenCache(redis: KV, key: string): Promise<void> {
await redis.del(key);
}

8
www/jest.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/app"],
testMatch: ["**/__tests__/**/*.test.ts"],
collectCoverage: true,
collectCoverageFrom: ["app/**/*.ts", "!app/**/*.d.ts"],
};

View File

@@ -8,7 +8,8 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write .", "format": "prettier --write .",
"openapi": "openapi-typescript http://127.0.0.1:1250/openapi.json -o ./app/reflector-api.d.ts" "openapi": "openapi-typescript http://127.0.0.1:1250/openapi.json -o ./app/reflector-api.d.ts",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/react": "^3.24.2", "@chakra-ui/react": "^3.24.2",
@@ -18,6 +19,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@sentry/nextjs": "^7.77.0", "@sentry/nextjs": "^7.77.0",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
"@types/ioredis": "^5.0.0",
"@vercel/edge-config": "^0.4.1", "@vercel/edge-config": "^0.4.1",
"@whereby.com/browser-sdk": "^3.3.4", "@whereby.com/browser-sdk": "^3.3.4",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
@@ -25,6 +27,7 @@
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-config-next": "^14.2.31", "eslint-config-next": "^14.2.31",
"fontawesome": "^5.6.3", "fontawesome": "^5.6.3",
"ioredis": "^5.7.0",
"jest-worker": "^29.6.2", "jest-worker": "^29.6.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^14.2.30", "next": "^14.2.30",
@@ -46,16 +49,20 @@
"simple-peer": "^9.11.1", "simple-peer": "^9.11.1",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "^5.1.6", "typescript": "^5.1.6",
"wavesurfer.js": "^7.4.2" "wavesurfer.js": "^7.4.2",
"zod": "^4.1.5"
}, },
"main": "index.js", "main": "index.js",
"repository": "https://github.com/Monadical-SAS/reflector-ui.git", "repository": "https://github.com/Monadical-SAS/reflector-ui.git",
"author": "Andreas <andreas@monadical.com>", "author": "Andreas <andreas@monadical.com>",
"license": "All Rights Reserved", "license": "All Rights Reserved",
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0",
"@types/react": "18.2.20", "@types/react": "18.2.20",
"jest": "^30.1.3",
"openapi-typescript": "^7.9.1", "openapi-typescript": "^7.9.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"ts-jest": "^29.4.1",
"vercel": "^37.3.0" "vercel": "^37.3.0"
}, },
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748" "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"

2496
www/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff