perf(dashboard): cachear my-tenants/my-apps del Hub por sesión estable
my-tenants y my-apps no tienen caché propia en el Hub — se repetían en cada +layout.server.ts load del dashboard, sin importar cuántas veces navegara el usuario en la misma sesión. - jwt.ts: getJwtSessionKey() extrae sid/sub del JWT — identificador estable que sobrevive al refresh del access token (rota cada ~60s). - dashboard-shell-cache.ts: caché en memoria (Map + TTL de 30s) para el bundle de tenants/apps, keyed por session key — NUNCA por el access token, que rotando cada ~60s haría que cada set()/get() usaran llaves distintas y la caché nunca acertara. - +layout.server.ts: usa el caché antes de golpear al Hub; lo llena tras el primer fetch exitoso de la sesión. - dashboard-shell-cache.test.ts: cubre hit/miss, expiración por TTL, y que sobrevive a la rotación del access token (llave estable). Verificado: 5/5 tests nuevos pasan; svelte-check da los mismos 38 errores/8 warnings preexistentes que main (0 nuevos); los 2 fallos de backend.test.ts son preexistentes (ENOTFOUND backend fuera de Docker, igual en main).
This commit is contained in:
49
frontend/src/lib/server/dashboard-shell-cache.test.ts
Normal file
49
frontend/src/lib/server/dashboard-shell-cache.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
getDashboardShellCache,
|
||||
setDashboardShellCache,
|
||||
DASHBOARD_SHELL_CACHE_TTL_MS
|
||||
} from './dashboard-shell-cache';
|
||||
|
||||
const SAMPLE_DATA = {
|
||||
userTenants: [{ id: 1, name: 'Tenant A', slug: 'tenant-a' }],
|
||||
myApps: { apps: [{ id: 'app-1' }], routing: 'default' }
|
||||
};
|
||||
|
||||
describe('dashboard-shell-cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('devuelve null si no hay nada cacheado para esa llave', () => {
|
||||
expect(getDashboardShellCache('sesion-inexistente')).toBeNull();
|
||||
});
|
||||
|
||||
it('devuelve lo cacheado dentro del TTL', () => {
|
||||
setDashboardShellCache('sesion-1', SAMPLE_DATA);
|
||||
expect(getDashboardShellCache('sesion-1')).toEqual(SAMPLE_DATA);
|
||||
});
|
||||
|
||||
it('sigue acertando aunque el "access token" hubiera rotado — la llave es el session key, no el token', () => {
|
||||
// Simula dos navegaciones con tokens de acceso distintos (rotación cada ~60s)
|
||||
// pero el mismo session key estable (sid/sub) — como haría getJwtSessionKey().
|
||||
setDashboardShellCache('mismo-session-key', SAMPLE_DATA);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(getDashboardShellCache('mismo-session-key')).toEqual(SAMPLE_DATA);
|
||||
});
|
||||
|
||||
it('expira después del TTL', () => {
|
||||
setDashboardShellCache('sesion-2', SAMPLE_DATA);
|
||||
vi.advanceTimersByTime(DASHBOARD_SHELL_CACHE_TTL_MS + 1);
|
||||
expect(getDashboardShellCache('sesion-2')).toBeNull();
|
||||
});
|
||||
|
||||
it('no acierta entre llaves distintas (sesiones distintas)', () => {
|
||||
setDashboardShellCache('sesion-usuario-a', SAMPLE_DATA);
|
||||
expect(getDashboardShellCache('sesion-usuario-b')).toBeNull();
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/server/dashboard-shell-cache.ts
Normal file
33
frontend/src/lib/server/dashboard-shell-cache.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Caché en memoria del "shell" del dashboard (tenants y apps del Workspace) para no
|
||||
* golpear al Hub en cada navegación — esas dos llamadas no tienen caché propia en el
|
||||
* Hub y antes se repetían en cada `+layout.server.ts` load.
|
||||
*
|
||||
* Llave = session key estable (ver jwt.ts), NUNCA el access token: el token rota cada
|
||||
* ~60s, así que usarlo como llave produce 0% de aciertos entre navegaciones.
|
||||
*/
|
||||
|
||||
export const DASHBOARD_SHELL_CACHE_TTL_MS = 30_000;
|
||||
|
||||
export interface DashboardShellData {
|
||||
userTenants: { id: number; name: string; slug: string }[];
|
||||
myApps: { apps: unknown[]; routing: unknown };
|
||||
}
|
||||
|
||||
const cache = new Map<string, { data: DashboardShellData; expiresAt: number }>();
|
||||
|
||||
export function getDashboardShellCache(sessionKey: string): DashboardShellData | null {
|
||||
const entry = cache.get(sessionKey);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(sessionKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function setDashboardShellCache(sessionKey: string, data: DashboardShellData): void {
|
||||
cache.set(sessionKey, { data, expiresAt: Date.now() + DASHBOARD_SHELL_CACHE_TTL_MS });
|
||||
}
|
||||
35
frontend/src/lib/server/jwt.ts
Normal file
35
frontend/src/lib/server/jwt.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Decodifica el payload de un JWT sin verificar firma (el backend ya lo valida contra
|
||||
* el Hub) para extraer un identificador de sesión estable.
|
||||
*/
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
if (!payload) return null;
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ID de sesión estable que sobrevive al refresh del access token: `sid` de Keycloak,
|
||||
* o `sub` (usuario) como respaldo.
|
||||
*
|
||||
* El access token rota cada ~60s (ver token_cache en backend/core/security.py) —
|
||||
* usar el propio token (o parte de él) como llave de caché hace que cada refresh
|
||||
* invalide la caché sin motivo real, dejándola con 0% de aciertos.
|
||||
*/
|
||||
export function getJwtSessionKey(token: string): string | null {
|
||||
const payload = decodeJwtPayload(token);
|
||||
if (!payload) return null;
|
||||
|
||||
const sid = payload['sid'];
|
||||
if (typeof sid === 'string' && sid) return sid;
|
||||
|
||||
const sub = payload['sub'];
|
||||
if (typeof sub === 'string' && sub) return sub;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
|
||||
import { resolveActiveCompanyId } from '$lib/server/system-gate';
|
||||
import { fetchMyApps } from '$lib/server/workspace-apps';
|
||||
import { getJwtSessionKey } from '$lib/server/jwt';
|
||||
import { getDashboardShellCache, setDashboardShellCache } from '$lib/server/dashboard-shell-cache';
|
||||
|
||||
const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true';
|
||||
|
||||
@@ -30,6 +32,15 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null };
|
||||
|
||||
if (!DEV_LOCAL_AUTH) {
|
||||
// my-tenants y my-apps no tienen caché propia en el Hub — sin esto, cada
|
||||
// navegación al dashboard las repetía (ver dashboard-shell-cache.ts).
|
||||
const sessionKey = getJwtSessionKey(accessToken);
|
||||
const cachedShell = sessionKey ? getDashboardShellCache(sessionKey) : null;
|
||||
|
||||
if (cachedShell) {
|
||||
userTenants = cachedShell.userTenants;
|
||||
myApps = cachedShell.myApps;
|
||||
} else {
|
||||
try {
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
const tenantOverride = cookies.get('sso_tenant_id');
|
||||
@@ -48,6 +59,11 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
|
||||
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken;
|
||||
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id'));
|
||||
|
||||
if (sessionKey) {
|
||||
setDashboardShellCache(sessionKey, { userTenants, myApps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user