diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 1e08b9d7..7619f957 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -4,6 +4,16 @@ export interface RPCOptions { method: string params?: Record timeout?: number + /** Abort the call (and any pending retries) from the outside — pass a + * component-scoped controller's signal so fan-outs stop on unmount. */ + signal?: AbortSignal + /** Per-call retry budget (default 3). Use 1 for calls whose caller has its + * own timeout/fallback UX — retry×3 on a slow peer is how one unreachable + * node turns a 30s timeout into a 90s spinner. */ + maxRetries?: number + /** Collapse concurrent identical calls (same method + params) into one + * request. Opt-in: only safe for reads. */ + dedup?: boolean } export interface RPCResponse { @@ -74,18 +84,35 @@ function getCsrfToken(): string | null { class RPCClient { private static _sessionExpiredRedirecting = false private baseUrl: string + /** In-flight dedup map for `dedup: true` calls, keyed method+params. */ + private inflight = new Map>() constructor(baseUrl: string = '/rpc/v1') { this.baseUrl = baseUrl } async call(options: RPCOptions): Promise { - const { method, params = {}, timeout = 15000 } = options - const maxRetries = 3 + if (options.dedup) { + const key = `${options.method}:${JSON.stringify(options.params ?? {})}` + const existing = this.inflight.get(key) + if (existing) return existing as Promise + const p = this.callInner(options).finally(() => this.inflight.delete(key)) + this.inflight.set(key, p) + return p + } + return this.callInner(options) + } + + private async callInner(options: RPCOptions): Promise { + const { method, params = {}, timeout = 15000, signal: external } = options + const maxRetries = Math.max(1, options.maxRetries ?? 3) for (let attempt = 0; attempt < maxRetries; attempt++) { + if (external?.aborted) throw new Error('Aborted') const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) + const onExternalAbort = () => controller.abort() + external?.addEventListener('abort', onExternalAbort, { once: true }) try { const headers: Record = { @@ -105,6 +132,7 @@ class RPCClient { }) clearTimeout(timeoutId) + external?.removeEventListener('abort', onExternalAbort) if (!response.ok) { // Session expired — debounced redirect to login @@ -167,8 +195,11 @@ class RPCClient { return data.result as T } catch (error) { clearTimeout(timeoutId) + external?.removeEventListener('abort', onExternalAbort) if (error instanceof Error) { if (error.name === 'AbortError') { + // Caller-initiated abort is final — never retried. + if (external?.aborted) throw new Error('Aborted') const timeoutErr = new Error('Request timeout') if (attempt < maxRetries - 1) { const delay = 600 * (attempt + 1) diff --git a/neode-ui/src/composables/useCachedResource.ts b/neode-ui/src/composables/useCachedResource.ts new file mode 100644 index 00000000..9c2e74b8 --- /dev/null +++ b/neode-ui/src/composables/useCachedResource.ts @@ -0,0 +1,107 @@ +// Stale-while-revalidate resource hook over the shared resources store. +// +// Usage: +// const files = useCachedResource({ +// key: 'cloud.my-files', +// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }), +// ttlMs: 30_000, +// }) +// // template: files.data renders instantly on revisit (cache), while +// // files.loadState === 'refreshing' drives a subtle refresh indicator. +// +// Behavior: +// - Synchronous hydrate: memory (survives navigation) → sessionStorage +// snapshot (survives reload) → fetch. +// - Sticky-ready: never regresses ready → loading; refreshes are +// 'refreshing' so content stays on screen. +// - Stale-while-revalidate: on mount, cached data is shown immediately and a +// background refresh runs only if the TTL has lapsed (or never fetched). +// - Keep-last-value on error, with `isStale`/`ageMs` for badges. +// - revalidateOnFocus: refreshes when the tab regains focus and the data is +// stale (debounced by TTL, so focus-flapping is free). +// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when +// the last subscribed component unmounts. + +import { computed, getCurrentScope, onScopeDispose } from 'vue' +import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources' + +export interface CachedResourceOptions { + /** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */ + key: string + /** Fetch fresh data. Receives an abort signal tied to component lifetime. */ + fetcher: (signal: AbortSignal) => Promise + /** Data older than this triggers a background revalidate (default 30s). */ + ttlMs?: number + /** Snapshot to sessionStorage so reloads paint instantly (default true). + * Disable for large payloads. */ + persist?: boolean + /** Revalidate (if stale) when the window regains focus (default true). */ + revalidateOnFocus?: boolean + /** Fetch on first use (default true). Set false for lazy resources. */ + immediate?: boolean +} + +export interface CachedResource { + entry: ResourceEntry + /** Convenience computed views over the entry. */ + data: ReturnType> + loadState: ReturnType> + error: ReturnType> + /** True when data exists but is older than the TTL (drive an age badge). */ + isStale: ReturnType> + ageMs: ReturnType> + /** Force a refresh now (deduped with any in-flight one). */ + refresh: () => Promise + /** Mark stale + debounce-refresh all mounted users of this key. */ + invalidate: () => void + /** Optimistically update cached data; returns rollback for RPC failure. */ + optimistic: (update: (current: T | null) => T) => () => void +} + +export function useCachedResource(opts: CachedResourceOptions): CachedResource { + const store = useResourcesStore() + const ttlMs = opts.ttlMs ?? 30_000 + const persist = opts.persist ?? true + const entry = store.entry(opts.key, persist) + + const aborter = new AbortController() + const fetcher = () => opts.fetcher(aborter.signal) + const refresh = () => store.refresh(opts.key, fetcher, { persist }) + + const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs + const refreshIfStale = () => { + if (stale()) void refresh() + } + + // Register as a live revalidator so invalidate(key) reaches us. + const unsubscribe = store.subscribe(opts.key, () => void refresh()) + + const onFocus = () => refreshIfStale() + if (opts.revalidateOnFocus ?? true) { + window.addEventListener('focus', onFocus) + } + + // Tied to the owning effect scope (component setup or manual scope); + // outside any scope (tests, module init) there's nothing to dispose. + if (getCurrentScope()) { + onScopeDispose(() => { + unsubscribe() + window.removeEventListener('focus', onFocus) + aborter.abort() + }) + } + + if (opts.immediate ?? true) refreshIfStale() + + return { + entry, + data: computed(() => entry.data), + loadState: computed(() => entry.loadState), + error: computed(() => entry.error), + isStale: computed(() => entry.data !== null && stale()), + ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)), + refresh, + invalidate: () => store.invalidate(opts.key), + optimistic: (update) => store.optimistic(opts.key, update), + } +} diff --git a/neode-ui/src/stores/__tests__/resources.test.ts b/neode-ui/src/stores/__tests__/resources.test.ts new file mode 100644 index 00000000..d96efb23 --- /dev/null +++ b/neode-ui/src/stores/__tests__/resources.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +import { useResourcesStore } from '../resources' +import { useCachedResource } from '@/composables/useCachedResource' + +describe('resources store — stale-while-revalidate semantics', () => { + beforeEach(() => { + setActivePinia(createPinia()) + sessionStorage.clear() + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('first fetch goes idle → loading → ready with data', async () => { + const store = useResourcesStore() + const e = store.entry('k1') + expect(e.loadState).toBe('idle') + const p = store.refresh('k1', async () => 'hello') + expect(e.loadState).toBe('loading') + await p + expect(e.loadState).toBe('ready') + expect(e.data).toBe('hello') + expect(e.fetchedAt).not.toBeNull() + }) + + it('sticky-ready: refresh never regresses ready → loading', async () => { + const store = useResourcesStore() + await store.refresh('k2', async () => 1) + const e = store.entry('k2') + const p = store.refresh('k2', async () => 2) + expect(e.loadState).toBe('refreshing') + await p + expect(e.loadState).toBe('ready') + expect(e.data).toBe(2) + }) + + it('keeps last-known data on refresh error (ready + error set)', async () => { + const store = useResourcesStore() + await store.refresh('k3', async () => 'good') + const e = store.entry('k3') + await store.refresh('k3', async () => { + throw new Error('boom') + }) + expect(e.data).toBe('good') + expect(e.loadState).toBe('ready') + expect(e.error).toBe('boom') + }) + + it('errors with no prior data land in error state', async () => { + const store = useResourcesStore() + await store.refresh('k4', async () => { + throw new Error('down') + }) + const e = store.entry('k4') + expect(e.loadState).toBe('error') + expect(e.data).toBeNull() + }) + + it('dedups concurrent refreshes for the same key', async () => { + const store = useResourcesStore() + const fetcher = vi.fn(async () => 'once') + const p1 = store.refresh('k5', fetcher) + const p2 = store.refresh('k5', fetcher) + await Promise.all([p1, p2]) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('hydrates a new entry from the sessionStorage snapshot', async () => { + const store = useResourcesStore() + await store.refresh('k6', async () => ({ n: 42 })) + // Fresh pinia = fresh memory cache, same sessionStorage. + setActivePinia(createPinia()) + const store2 = useResourcesStore() + const e = store2.entry<{ n: number }>('k6') + expect(e.loadState).toBe('ready') + expect(e.data).toEqual({ n: 42 }) + }) + + it('optimistic update applies immediately and rollback restores', async () => { + const store = useResourcesStore() + await store.refresh('k7', async () => ['a']) + const e = store.entry('k7') + const rollback = store.optimistic('k7', (cur) => [...(cur ?? []), 'b']) + expect(e.data).toEqual(['a', 'b']) + rollback() + expect(e.data).toEqual(['a']) + }) + + it('invalidate marks stale and debounce-runs subscribers', async () => { + const store = useResourcesStore() + await store.refresh('k8', async () => 1) + const revalidate = vi.fn() + store.subscribe('k8', revalidate) + store.invalidate('k8') + expect(store.entry('k8').fetchedAt).toBeNull() + expect(revalidate).not.toHaveBeenCalled() + vi.advanceTimersByTime(900) + expect(revalidate).toHaveBeenCalledTimes(1) + }) +}) + +describe('useCachedResource composable', () => { + beforeEach(() => { + setActivePinia(createPinia()) + sessionStorage.clear() + }) + + it('fetches immediately when stale and exposes reactive views', async () => { + const fetcher = vi.fn(async () => 'data') + const r = useCachedResource({ key: 'c1', fetcher, revalidateOnFocus: false }) + await r.refresh() + expect(fetcher).toHaveBeenCalled() + expect(r.data.value).toBe('data') + expect(r.loadState.value).toBe('ready') + expect(r.isStale.value).toBe(false) + }) + + it('does not refetch within TTL (instant render from cache)', async () => { + const fetcher = vi.fn(async () => 'v1') + const r1 = useCachedResource({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false }) + await r1.refresh() + // Second component using the same key inside the TTL: no new fetch. + const fetcher2 = vi.fn(async () => 'v2') + const r2 = useCachedResource({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false }) + expect(r2.data.value).toBe('v1') + expect(fetcher2).not.toHaveBeenCalled() + }) +}) diff --git a/neode-ui/src/stores/resources.ts b/neode-ui/src/stores/resources.ts new file mode 100644 index 00000000..d0ea37ea --- /dev/null +++ b/neode-ui/src/stores/resources.ts @@ -0,0 +1,166 @@ +// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer). +// +// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard +// keys its router-view by route.path, so each visit unmounted and refetched +// everything. This store is the single place resource state lives instead: +// keyed entries survive navigation (Pinia) and reloads (sessionStorage +// snapshot), and `useCachedResource` renders them instantly while +// revalidating in the background. +// +// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven +// hand-rolled versions): +// - sticky-ready: once a key is 'ready' it never regresses to 'loading'; +// refreshes show as 'refreshing' so the UI keeps the data visible. +// - keep-last-known-value on error: a failed revalidate leaves data in place +// (with `error` set and `fetchedAt` untouched → age badge shows staleness). +// - in-flight dedup per key: concurrent refreshes collapse into one fetch. + +import { defineStore } from 'pinia' +import { reactive } from 'vue' + +export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error' + +export interface ResourceEntry { + data: T | null + loadState: ResourceLoadState + /** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */ + fetchedAt: number | null + error: string | null +} + +const SNAPSHOT_PREFIX = 'resource:' + +function readSnapshot(key: string): { data: T; fetchedAt: number } | null { + try { + const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key) + if (!raw) return null + const parsed = JSON.parse(raw) + if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed + } catch { + /* corrupt/absent snapshot — fall through to a fresh fetch */ + } + return null +} + +function writeSnapshot(key: string, data: unknown, fetchedAt: number): void { + try { + sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt })) + } catch { + /* quota exceeded or unserializable — memory cache still works */ + } +} + +export const useResourcesStore = defineStore('resources', () => { + const entries = reactive(new Map()) + // Non-reactive bookkeeping: in-flight fetches + active revalidators. + const inflight = new Map>() + const revalidators = new Map void>>() + const invalidateTimers = new Map>() + + /** Get (or create) the reactive entry for a key, hydrating from the + * sessionStorage snapshot on first sight so revisits after a reload paint + * before any RPC completes. Pass `persist: false` to skip snapshots. */ + function entry(key: string, persist = true): ResourceEntry { + let e = entries.get(key) + if (!e) { + const snap = persist ? readSnapshot(key) : null + e = reactive({ + data: snap ? snap.data : null, + loadState: snap ? 'ready' : 'idle', + fetchedAt: snap ? snap.fetchedAt : null, + error: null, + }) + entries.set(key, e) + } + return e as ResourceEntry + } + + /** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics. + * Concurrent calls for the same key share one in-flight fetch. */ + function refresh( + key: string, + fetcher: () => Promise, + opts: { persist?: boolean } = {}, + ): Promise { + const existing = inflight.get(key) + if (existing) return existing + const e = entry(key, opts.persist ?? true) + e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading' + const p = (async () => { + try { + const data = await fetcher() + e.data = data + e.error = null + e.fetchedAt = Date.now() + e.loadState = 'ready' + if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt) + } catch (err) { + e.error = err instanceof Error ? err.message : String(err) + // Keep last-known data visible; only 'error' when we have nothing. + e.loadState = e.data !== null ? 'ready' : 'error' + } finally { + inflight.delete(key) + } + })() + inflight.set(key, p) + return p + } + + /** Mark a key stale and (debounced) re-run every mounted subscriber's + * fetcher. Call after a mutation or on a relevant WS push. */ + function invalidate(key: string, opts: { debounceMs?: number } = {}): void { + const e = entries.get(key) + if (e) e.fetchedAt = null + const subs = revalidators.get(key) + if (!subs || subs.size === 0) return + const t = invalidateTimers.get(key) + if (t) clearTimeout(t) + invalidateTimers.set( + key, + setTimeout(() => { + invalidateTimers.delete(key) + for (const fn of subs) fn() + }, opts.debounceMs ?? 800), + ) + } + + /** Register a live revalidator for a key (used by useCachedResource); + * returns an unsubscribe fn. */ + function subscribe(key: string, revalidate: () => void): () => void { + let subs = revalidators.get(key) + if (!subs) { + subs = new Set() + revalidators.set(key, subs) + } + subs.add(revalidate) + return () => { + subs.delete(revalidate) + } + } + + /** Optimistically apply `update` to the cached value; returns a rollback. + * Pattern: rollback on RPC failure (generalized TransportPrefsCard). */ + function optimistic(key: string, update: (current: T | null) => T): () => void { + const e = entry(key) + const before = e.data + const beforeState = e.loadState + e.data = update(before) + if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready' + return () => { + e.data = before + e.loadState = beforeState + } + } + + /** Drop a key entirely (memory + snapshot). */ + function evict(key: string): void { + entries.delete(key) + try { + sessionStorage.removeItem(SNAPSHOT_PREFIX + key) + } catch { + /* noop */ + } + } + + return { entries, entry, refresh, invalidate, subscribe, optimistic, evict } +})