Skip to content

Commit 05c9157

Browse files
committed
aggressive caching, other changes
1 parent 74d556d commit 05c9157

File tree

9 files changed

+340
-67
lines changed

9 files changed

+340
-67
lines changed

ui/server-start.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,11 @@ const server = createServer(async (req, res) => {
7777
const ssrTime = Date.now() - ssrStart;
7878

7979
// Log SSR timing with request details for profiling
80-
if (ssrTime > 1000) {
80+
if (ssrTime > 2000) {
81+
console.log(`🔥 VERY SLOW SSR: ${req.method} ${pathname} took ${ssrTime}ms`);
82+
} else if (ssrTime > 1000) {
8183
console.log(`⚠️ SLOW SSR: ${req.method} ${pathname} took ${ssrTime}ms`);
82-
} else if (ssrTime > 200) {
84+
} else if (ssrTime > 500) {
8385
console.log(`⏱️ SSR: ${req.method} ${pathname} took ${ssrTime}ms`);
8486
} else if (process.env.DEBUG === 'true') {
8587
console.log(`✅ SSR: ${req.method} ${pathname} took ${ssrTime}ms`);

ui/src/authkit/serverFunctions.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,24 @@ export const ensureOrgExists = createServerFn({method: 'GET'})
134134
export const getWidgetsAuthToken = createServerFn({method: 'GET'})
135135
.inputValidator((args: {userId: string, organizationId: string, scopes?: WidgetScope[]}) => args)
136136
.handler(async ({data: {userId, organizationId, scopes}}) : Promise<string> => {
137-
return getWorkOS().widgets.getToken({
137+
// Check cache first
138+
const cached = serverCache.getWidgetToken(userId, organizationId);
139+
if (cached) {
140+
console.log(`✅ Widget token cache hit for ${userId}:${organizationId}`);
141+
return cached;
142+
}
143+
144+
// Cache miss - generate new token
145+
console.log(`❌ Widget token cache miss, generating new token for ${userId}:${organizationId}`);
146+
const token = await getWorkOS().widgets.getToken({
138147
userId: userId,
139148
organizationId: organizationId,
140149
scopes: scopes ?? ['widgets:users-table:manage'] as WidgetScope[],
141150
});
151+
152+
// Store in cache
153+
serverCache.setWidgetToken(userId, organizationId, token);
154+
155+
return token;
142156
})
143157

ui/src/authkit/ssr/workos_api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,22 @@ export async function createOrgForUser(userId: string, orgName: string) {
2323

2424
export async function listUserOrganizationMemberships(userId: string) {
2525
try {
26+
// Check cache first
27+
const cachedMemberships = serverCache.getUserMemberships(userId);
28+
if (cachedMemberships) {
29+
console.log(`✅ User memberships cache hit for ${userId}`);
30+
return cachedMemberships;
31+
}
32+
33+
// Cache miss - fetch from WorkOS
34+
console.log(`❌ User memberships cache miss, fetching from WorkOS for ${userId}`);
2635
const memberships = await getWorkOS().userManagement.listOrganizationMemberships({
2736
userId,
2837
});
2938

39+
// Store full membership objects in cache
40+
serverCache.setUserMemberships(userId, memberships.data);
41+
3042
return memberships.data;
3143
} catch (error) {
3244
console.error('Error fetching user memberships:', error);
@@ -55,9 +67,22 @@ export async function getOrganisationDetails(orgId: string) {
5567

5668
export async function getOranizationsForUser(userId: string) {
5769
try {
70+
// Check cache first
71+
const cachedMemberships = serverCache.getUserMemberships(userId);
72+
if (cachedMemberships) {
73+
console.log(`✅ Organizations cache hit for ${userId}`);
74+
return cachedMemberships;
75+
}
76+
77+
// Cache miss - fetch from WorkOS
78+
console.log(`❌ Organizations cache miss, fetching from WorkOS for ${userId}`);
5879
const memberships = await getWorkOS().userManagement.listOrganizationMemberships({
5980
userId: userId,
6081
});
82+
83+
// Store full membership objects in cache
84+
serverCache.setUserMemberships(userId, memberships.data);
85+
6186
return memberships.data;
6287
} catch (error) {
6388
console.error('Error fetching user organizations:', error);

ui/src/lib/cache.server.ts

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
// Server-side in-memory cache for WorkOS organization details ONLY
1+
// Aggressive server-side caching for WorkOS data
22
//
3-
// ⚠️ IMPORTANT: This cache is ONLY for WorkOS organization metadata (name, id)
3+
// ⚠️ IMPORTANT: This cache is ONLY for WorkOS metadata
44
// DO NOT cache core application data like:
55
// - Units, unit versions, unit status
66
// - Projects, project status
@@ -16,12 +16,27 @@ interface CacheEntry<T> {
1616
expiresAt: number;
1717
}
1818

19-
class OrgCache {
19+
interface UserMembership {
20+
userId: string;
21+
memberships: any[]; // Store full membership objects from WorkOS
22+
}
23+
24+
class AggressiveWorkOSCache {
25+
// Organization cache - 30 minutes (org names rarely change)
2026
private readonly orgCache = new Map<string, CacheEntry<Organization>>();
21-
22-
// Cache organization details for 5 minutes
23-
// Org names/details rarely change, so this is safe
24-
private readonly ORG_TTL = 5 * 60 * 1000;
27+
private readonly ORG_TTL = 30 * 60 * 1000; // 30 minutes
28+
29+
// User memberships cache - 15 minutes (users don't switch orgs often)
30+
private readonly membershipCache = new Map<string, CacheEntry<UserMembership>>();
31+
private readonly MEMBERSHIP_TTL = 15 * 60 * 1000; // 15 minutes
32+
33+
// Widget tokens cache - 5 minutes (tokens expire quickly)
34+
private readonly widgetTokenCache = new Map<string, CacheEntry<string>>();
35+
private readonly WIDGET_TOKEN_TTL = 5 * 60 * 1000; // 5 minutes
36+
37+
// ============================================================================
38+
// ORGANIZATION CACHE
39+
// ============================================================================
2540

2641
getOrg(orgId: string): Organization | null {
2742
const entry = this.orgCache.get(orgId);
@@ -46,26 +61,125 @@ class OrgCache {
4661
this.orgCache.delete(orgId);
4762
}
4863

49-
// Clear expired entries periodically
64+
// ============================================================================
65+
// USER MEMBERSHIPS CACHE
66+
// ============================================================================
67+
68+
getUserMemberships(userId: string): any[] | null {
69+
const entry = this.membershipCache.get(userId);
70+
if (!entry) return null;
71+
72+
if (Date.now() > entry.expiresAt) {
73+
this.membershipCache.delete(userId);
74+
return null;
75+
}
76+
77+
return entry.data.memberships;
78+
}
79+
80+
setUserMemberships(userId: string, memberships: any[]): void {
81+
this.membershipCache.set(userId, {
82+
data: { userId, memberships },
83+
expiresAt: Date.now() + this.MEMBERSHIP_TTL,
84+
});
85+
}
86+
87+
clearUserMemberships(userId: string): void {
88+
this.membershipCache.delete(userId);
89+
}
90+
91+
// ============================================================================
92+
// WIDGET TOKEN CACHE
93+
// ============================================================================
94+
95+
getWidgetToken(userId: string, orgId: string): string | null {
96+
const key = `${userId}:${orgId}`;
97+
const entry = this.widgetTokenCache.get(key);
98+
if (!entry) return null;
99+
100+
if (Date.now() > entry.expiresAt) {
101+
this.widgetTokenCache.delete(key);
102+
return null;
103+
}
104+
105+
return entry.data;
106+
}
107+
108+
setWidgetToken(userId: string, orgId: string, token: string): void {
109+
const key = `${userId}:${orgId}`;
110+
this.widgetTokenCache.set(key, {
111+
data: token,
112+
expiresAt: Date.now() + this.WIDGET_TOKEN_TTL,
113+
});
114+
}
115+
116+
clearWidgetToken(userId: string, orgId: string): void {
117+
const key = `${userId}:${orgId}`;
118+
this.widgetTokenCache.delete(key);
119+
}
120+
121+
// ============================================================================
122+
// CACHE MANAGEMENT
123+
// ============================================================================
124+
50125
cleanExpired(): void {
51126
const now = Date.now();
127+
128+
// Clean org cache
52129
for (const [key, entry] of this.orgCache.entries()) {
53130
if (now > entry.expiresAt) {
54131
this.orgCache.delete(key);
55132
}
56133
}
134+
135+
// Clean membership cache
136+
for (const [key, entry] of this.membershipCache.entries()) {
137+
if (now > entry.expiresAt) {
138+
this.membershipCache.delete(key);
139+
}
140+
}
141+
142+
// Clean widget token cache
143+
for (const [key, entry] of this.widgetTokenCache.entries()) {
144+
if (now > entry.expiresAt) {
145+
this.widgetTokenCache.delete(key);
146+
}
147+
}
57148
}
58149

59-
// Get cache stats for monitoring
60150
getStats() {
61151
return {
62152
orgCacheSize: this.orgCache.size,
153+
membershipCacheSize: this.membershipCache.size,
154+
widgetTokenCacheSize: this.widgetTokenCache.size,
63155
};
64156
}
157+
158+
// Invalidate all caches for a user (when they switch orgs, etc.)
159+
invalidateUser(userId: string): void {
160+
this.clearUserMemberships(userId);
161+
// Clear all widget tokens for this user
162+
for (const key of this.widgetTokenCache.keys()) {
163+
if (key.startsWith(`${userId}:`)) {
164+
this.widgetTokenCache.delete(key);
165+
}
166+
}
167+
}
168+
169+
// Invalidate all caches for an org (when org details change)
170+
invalidateOrg(orgId: string): void {
171+
this.clearOrg(orgId);
172+
// Clear all widget tokens for this org
173+
for (const key of this.widgetTokenCache.keys()) {
174+
if (key.endsWith(`:${orgId}`)) {
175+
this.widgetTokenCache.delete(key);
176+
}
177+
}
178+
}
65179
}
66180

67181
// Single instance per server process
68-
export const serverCache = new OrgCache();
182+
export const serverCache = new AggressiveWorkOSCache();
69183

70184
// Clean up expired entries every minute
71185
setInterval(() => serverCache.cleanExpired(), 60 * 1000);

ui/src/lib/token-cache.server.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Token verification cache - tokens are validated frequently in TFE proxy
2+
// Cache valid tokens for 5 minutes to avoid repeated verification calls
3+
4+
interface TokenCacheEntry {
5+
userId: string;
6+
userEmail: string;
7+
orgId: string;
8+
expiresAt: number;
9+
}
10+
11+
class TokenCache {
12+
private readonly cache = new Map<string, TokenCacheEntry>();
13+
private readonly TTL = 5 * 60 * 1000; // 5 minutes
14+
15+
get(token: string): TokenCacheEntry | null {
16+
const entry = this.cache.get(token);
17+
if (!entry) return null;
18+
19+
if (Date.now() > entry.expiresAt) {
20+
this.cache.delete(token);
21+
return null;
22+
}
23+
24+
return entry;
25+
}
26+
27+
set(token: string, userId: string, userEmail: string, orgId: string): void {
28+
this.cache.set(token, {
29+
userId,
30+
userEmail,
31+
orgId,
32+
expiresAt: Date.now() + this.TTL
33+
});
34+
}
35+
36+
clear(token: string): void {
37+
this.cache.delete(token);
38+
}
39+
40+
cleanExpired(): void {
41+
const now = Date.now();
42+
for (const [token, entry] of this.cache.entries()) {
43+
if (now > entry.expiresAt) {
44+
this.cache.delete(token);
45+
}
46+
}
47+
}
48+
49+
getStats() {
50+
return {
51+
size: this.cache.size,
52+
entries: Array.from(this.cache.keys()).length
53+
};
54+
}
55+
}
56+
57+
export const tokenCache = new TokenCache();
58+
59+
// Clean expired tokens every minute
60+
setInterval(() => tokenCache.cleanExpired(), 60 * 1000);
61+

ui/src/routes/__root.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,26 @@ import { getPublicServerConfig, type Env } from '@/lib/env.server';
1313

1414
export const Route = createRootRoute({
1515
beforeLoad: async () => {
16-
const { auth, organisationId } = await getAuth();
17-
const organisationDetails = organisationId ? await getOrganisationDetails({data: {organizationId: organisationId}}) : null;
18-
const publicServerConfig : Env = await getPublicServerConfig()
16+
const startRootLoad = Date.now();
17+
18+
// Run auth and config in parallel (they don't depend on each other)
19+
const [authResult, publicServerConfig] = await Promise.all([
20+
getAuth(),
21+
getPublicServerConfig()
22+
]);
23+
24+
const { auth, organisationId } = authResult;
25+
26+
// Get org details if we have an orgId (this depends on auth)
27+
const organisationDetails = organisationId
28+
? await getOrganisationDetails({data: {organizationId: organisationId}})
29+
: null;
30+
31+
const totalTime = Date.now() - startRootLoad;
32+
if (totalTime > 500) {
33+
console.log(`⚠️ Root loader took ${totalTime}ms`);
34+
}
35+
1936
return { user: auth.user, organisationId, role: auth.role, organisationName: organisationDetails?.name, publicServerConfig };
2037
},
2138
head: () => ({

ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,22 @@ export const Route = createFileRoute(
3131
component: RouteComponent,
3232
pendingComponent: PageLoading,
3333
loader: async ({ context }) => {
34+
const startLoad = Date.now();
3435
const { user, organisationId } = context;
35-
const unitsData = await listUnitsFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || ''}})
36+
37+
const unitsData = await listUnitsFn({
38+
data: {
39+
organisationId: organisationId || '',
40+
userId: user?.id || '',
41+
email: user?.email || ''
42+
}
43+
});
44+
45+
const loadTime = Date.now() - startLoad;
46+
if (loadTime > 1000) {
47+
console.log(`⚠️ Units loader took ${loadTime}ms (listUnits call)`);
48+
}
49+
3650
return { unitsData: unitsData, user, organisationId }
3751
}
3852
})

ui/src/routes/api/auth/workos/switch-org.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { decodeJwt } from 'jose'
33
import { getWorkOS } from '@/authkit/ssr/workos'
44
import { getSessionFromCookie, saveSession } from '@/authkit/ssr/session'
55
import type { AccessToken } from '@workos-inc/node'
6+
import { serverCache } from '@/lib/cache.server'
67

78
export const Route = createFileRoute('/api/auth/workos/switch-org')({
89
server: {
@@ -31,6 +32,12 @@ export const Route = createFileRoute('/api/auth/workos/switch-org')({
3132
})
3233

3334
await saveSession(refreshResult)
35+
36+
// Invalidate cache for this user when switching orgs
37+
if (session.user?.id) {
38+
serverCache.invalidateUser(session.user.id);
39+
console.log(`🔄 Cache invalidated for user ${session.user.id} (org switch)`);
40+
}
3441
} catch (err: any) {
3542
const code = err?.error
3643
if (code === 'sso_required' || code === 'mfa_enrollment') {

0 commit comments

Comments
 (0)