mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Improve app startup and route fallbacks
This commit is contained in:
@@ -46,6 +46,8 @@ interface RefreshSuccess {
|
||||
|
||||
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||
|
||||
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -312,6 +314,25 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
|
||||
}
|
||||
}
|
||||
|
||||
function refreshKey(session: SessionState): string {
|
||||
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
|
||||
return `token:${session.refreshToken || ''}`;
|
||||
}
|
||||
|
||||
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
|
||||
const key = refreshKey(session);
|
||||
const existing = pendingRefreshes.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = refreshAccessToken(session).finally(() => {
|
||||
if (pendingRefreshes.get(key) === request) {
|
||||
pendingRefreshes.delete(key);
|
||||
}
|
||||
});
|
||||
pendingRefreshes.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||
const body = new URLSearchParams();
|
||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||
@@ -436,7 +457,16 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
let resp = await retryableRequest(headers);
|
||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
const latest = getSession();
|
||||
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
||||
const latestHeaders = new Headers(init.headers || {});
|
||||
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
||||
resp = await retryableRequest(latestHeaders);
|
||||
if (resp.status !== 401) return resp;
|
||||
}
|
||||
|
||||
const refreshSource = latest || session;
|
||||
const refreshed = await refreshAccessTokenOnce(refreshSource);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) {
|
||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||
@@ -446,10 +476,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
...refreshSource,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
|
||||
@@ -16,6 +16,15 @@ function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCor
|
||||
return {
|
||||
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
||||
folders: Array.isArray(body?.folders) ? body!.folders! : [],
|
||||
sends: Array.isArray(body?.sends) ? body!.sends! : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCachedSnapshot(snapshot: Partial<VaultCoreSnapshot> | null | undefined): VaultCoreSnapshot {
|
||||
return {
|
||||
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
|
||||
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
|
||||
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,49 +35,70 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<Vaul
|
||||
if (memory) return memory.snapshot;
|
||||
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
if (!cached?.snapshot) return null;
|
||||
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||
memoryVaultCoreCache.set(normalizedKey, {
|
||||
revisionStamp: cached.revisionStamp,
|
||||
snapshot: cached.snapshot,
|
||||
snapshot,
|
||||
});
|
||||
return cached.snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
||||
const normalizedKey = String(cacheKey || '').trim();
|
||||
if (!normalizedKey) return { ciphers: [], folders: [] };
|
||||
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
||||
|
||||
const existing = pendingVaultCoreRequests.get(normalizedKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = (async () => {
|
||||
const revisionStamp = await getVaultRevisionDate(authedFetch);
|
||||
const memory = memoryVaultCoreCache.get(normalizedKey);
|
||||
if (memory?.revisionStamp === revisionStamp) {
|
||||
return memory.snapshot;
|
||||
}
|
||||
|
||||
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
|
||||
let cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
if (!memory && cached?.snapshot) {
|
||||
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||
memoryVaultCoreCache.set(normalizedKey, {
|
||||
revisionStamp,
|
||||
snapshot: cached.snapshot,
|
||||
revisionStamp: cached.revisionStamp,
|
||||
snapshot,
|
||||
});
|
||||
return cached.snapshot;
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to load vault');
|
||||
const body = await parseJson<VaultSyncResponse>(resp);
|
||||
const snapshot = normalizeSnapshot(body);
|
||||
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
|
||||
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
|
||||
return snapshot;
|
||||
try {
|
||||
const revisionStamp = await getVaultRevisionDate(authedFetch);
|
||||
const currentMemory = memoryVaultCoreCache.get(normalizedKey);
|
||||
if (currentMemory?.revisionStamp === revisionStamp) {
|
||||
return currentMemory.snapshot;
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
}
|
||||
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
|
||||
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||
memoryVaultCoreCache.set(normalizedKey, {
|
||||
revisionStamp,
|
||||
snapshot,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/sync', {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to load vault');
|
||||
const body = await parseJson<VaultSyncResponse>(resp);
|
||||
const snapshot = normalizeSnapshot(body);
|
||||
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
|
||||
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
const fallbackMemory = memoryVaultCoreCache.get(normalizedKey);
|
||||
if (fallbackMemory?.snapshot) return fallbackMemory.snapshot;
|
||||
if (cached?.snapshot) return normalizeCachedSnapshot(cached.snapshot);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
pendingVaultCoreRequests.set(normalizedKey, request);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
let workspacePreload: Promise<unknown> | null = null;
|
||||
let adminPreload: Promise<unknown> | null = null;
|
||||
|
||||
export function preloadAuthenticatedWorkspace(isAdmin: boolean): Promise<unknown> {
|
||||
if (!workspacePreload) {
|
||||
workspacePreload = Promise.allSettled([
|
||||
import('@/components/SendsPage'),
|
||||
import('@/components/TotpCodesPage'),
|
||||
import('@/components/SettingsPage'),
|
||||
import('@/components/SecurityDevicesPage'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return workspacePreload;
|
||||
}
|
||||
|
||||
if (!adminPreload) {
|
||||
adminPreload = Promise.allSettled([
|
||||
workspacePreload,
|
||||
import('@/components/AdminPage'),
|
||||
import('@/components/BackupCenterPage'),
|
||||
]);
|
||||
}
|
||||
|
||||
return adminPreload;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export type Locale =
|
||||
| 'ru'
|
||||
| 'es';
|
||||
|
||||
import enMessages from './i18n/locales/en';
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
||||
|
||||
type MessageTable = Record<string, string>;
|
||||
@@ -18,8 +20,8 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
|
||||
];
|
||||
|
||||
let locale: Locale = resolveInitialLocale();
|
||||
let activeMessages: MessageTable = {};
|
||||
const loadedMessages = new Map<Locale, MessageTable>();
|
||||
let activeMessages: MessageTable = enMessages;
|
||||
const loadedMessages = new Map<Locale, MessageTable>([['en', enMessages]]);
|
||||
|
||||
function isLocale(value: unknown): value is Locale {
|
||||
return AVAILABLE_LOCALES.some((item) => item.value === value);
|
||||
@@ -46,7 +48,7 @@ function resolveInitialLocale(): Locale {
|
||||
}
|
||||
|
||||
const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> = {
|
||||
en: () => import('./i18n/locales/en'),
|
||||
en: () => Promise.resolve({ default: enMessages }),
|
||||
'zh-CN': () => import('./i18n/locales/zh-CN'),
|
||||
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
||||
ru: () => import('./i18n/locales/ru'),
|
||||
@@ -63,11 +65,7 @@ async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
||||
}
|
||||
|
||||
async function loadFallbackMessages(): Promise<MessageTable> {
|
||||
const cached = loadedMessages.get('en');
|
||||
if (cached) return cached;
|
||||
const mod = await import('./i18n/locales/en');
|
||||
loadedMessages.set('en', mod.default);
|
||||
return mod.default;
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
export type I18nParams = Record<string, string | number | null | undefined>;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Cipher, Folder } from './types';
|
||||
import type { Cipher, Folder, Send } from './types';
|
||||
|
||||
export interface VaultCoreSnapshot {
|
||||
ciphers: Cipher[];
|
||||
folders: Folder[];
|
||||
sends: Send[];
|
||||
}
|
||||
|
||||
interface VaultCoreCacheRecord {
|
||||
|
||||
Reference in New Issue
Block a user