mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
redis cache
This commit is contained in:
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal file
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
REFRESH_ACCESS_TOKEN_BEFORE,
|
||||
REFRESH_ACCESS_TOKEN_ERROR,
|
||||
} 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)
|
||||
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
|
||||
@@ -55,7 +56,7 @@ export const authOptions: AuthOptions = {
|
||||
const expiresAtS = assertExists(account.expires_at);
|
||||
const expiresAtMs = expiresAtS * 1000;
|
||||
if (!account.access_token) {
|
||||
tokenCache.delete(KEY);
|
||||
await deleteTokenCache(tokenCacheRedis, KEY);
|
||||
} else {
|
||||
const jwtToken: JWTWithAccessToken = {
|
||||
...token,
|
||||
@@ -63,8 +64,7 @@ export const authOptions: AuthOptions = {
|
||||
accessTokenExpires: expiresAtMs,
|
||||
refreshToken: account.refresh_token,
|
||||
};
|
||||
// Store in memory cache
|
||||
tokenCache.set(KEY, {
|
||||
await setTokenCache(tokenCacheRedis, KEY, {
|
||||
token: jwtToken,
|
||||
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) {
|
||||
return currentToken.token;
|
||||
}
|
||||
@@ -109,10 +109,10 @@ async function lockedRefreshAccessToken(
|
||||
|
||||
const refreshPromise = (async () => {
|
||||
try {
|
||||
const cached = tokenCache.get(`token:${token.sub}`);
|
||||
const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
if (cached) {
|
||||
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) {
|
||||
return cached.token;
|
||||
}
|
||||
@@ -121,7 +121,7 @@ async function lockedRefreshAccessToken(
|
||||
const currentToken = cached?.token || (token as JWTWithAccessToken);
|
||||
const newToken = await refreshAccessToken(currentToken);
|
||||
|
||||
tokenCache.set(`token:${token.sub}`, {
|
||||
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
|
||||
token: newToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
2
www/app/lib/next.ts
Normal file
2
www/app/lib/next.ts
Normal 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");
|
||||
46
www/app/lib/redisClient.ts
Normal file
46
www/app/lib/redisClient.ts
Normal 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();
|
||||
61
www/app/lib/redisTokenCache.ts
Normal file
61
www/app/lib/redisTokenCache.ts
Normal 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
8
www/jest.config.js
Normal 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"],
|
||||
};
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"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": {
|
||||
"@chakra-ui/react": "^3.24.2",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@sentry/nextjs": "^7.77.0",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@types/ioredis": "^5.0.0",
|
||||
"@vercel/edge-config": "^0.4.1",
|
||||
"@whereby.com/browser-sdk": "^3.3.4",
|
||||
"autoprefixer": "10.4.20",
|
||||
@@ -25,6 +27,7 @@
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-next": "^14.2.31",
|
||||
"fontawesome": "^5.6.3",
|
||||
"ioredis": "^5.7.0",
|
||||
"jest-worker": "^29.6.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^14.2.30",
|
||||
@@ -46,16 +49,20 @@
|
||||
"simple-peer": "^9.11.1",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^5.1.6",
|
||||
"wavesurfer.js": "^7.4.2"
|
||||
"wavesurfer.js": "^7.4.2",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/Monadical-SAS/reflector-ui.git",
|
||||
"author": "Andreas <andreas@monadical.com>",
|
||||
"license": "All Rights Reserved",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "18.2.20",
|
||||
"jest": "^30.1.3",
|
||||
"openapi-typescript": "^7.9.1",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.4.1",
|
||||
"vercel": "^37.3.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
|
||||
|
||||
2496
www/pnpm-lock.yaml
generated
2496
www/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user