192 lines
6.1 KiB
TypeScript
192 lines
6.1 KiB
TypeScript
/*
|
|
* Multi-instance backend registry + per-backend scoped storage.
|
|
*
|
|
* The web UI is backend-agnostic: it can connect to any server speaking the
|
|
* `/api/v1` contract. Everything we persist (auth tokens, cached prefs, …) is
|
|
* therefore bound to the *instance it came from* — never global. Each saved
|
|
* backend gets a stable `id` derived from its base URL, and every persisted key
|
|
* lives under the `mcma:<id>:` namespace so switching backends can never mix
|
|
* one server's session/cache into another's.
|
|
*
|
|
* Layout in localStorage:
|
|
* mcma:instances -> Instance[] (the saved-backends registry)
|
|
* mcma:activeInstance -> <id> (which one is current)
|
|
* mcma:<id>:auth -> persisted auth slice (per-backend)
|
|
* mcma:<id>:<key> -> any other scoped value
|
|
*/
|
|
|
|
export interface Instance {
|
|
id: string;
|
|
baseUrl: string;
|
|
name: string;
|
|
lastUsedAt: number;
|
|
}
|
|
|
|
const REGISTRY_KEY = 'mcma:instances';
|
|
const ACTIVE_KEY = 'mcma:activeInstance';
|
|
|
|
// pre-multi-instance keys, migrated once on first load
|
|
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
|
const LEGACY_AUTH_KEY = 'mcma_auth';
|
|
|
|
// The UI always talks to the `/api/v1` contract, so users only enter the
|
|
// origin (and optional reverse-proxy prefix). We append the contract path
|
|
// here, the single choke point for both the base URL and the instance id, so
|
|
// `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge.
|
|
function normalizeUrl(url: string): string {
|
|
const trimmed = url.trim().replace(/\/+$/, '');
|
|
return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
|
|
}
|
|
|
|
/** Stable, readable id from a base URL — also serves as the storage namespace. */
|
|
export function instanceIdFromUrl(url: string): string {
|
|
const stripped = normalizeUrl(url)
|
|
.toLowerCase()
|
|
.replace(/^https?:\/\//, '');
|
|
const slug = stripped.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
return slug || 'default';
|
|
}
|
|
|
|
function defaultName(baseUrl: string): string {
|
|
try {
|
|
const u = new URL(
|
|
/^https?:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`,
|
|
);
|
|
return u.host || baseUrl;
|
|
} catch {
|
|
return baseUrl;
|
|
}
|
|
}
|
|
|
|
function readRegistry(): Instance[] {
|
|
try {
|
|
const raw = localStorage.getItem(REGISTRY_KEY);
|
|
return raw ? (JSON.parse(raw) as Instance[]) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeRegistry(list: Instance[]): void {
|
|
try {
|
|
localStorage.setItem(REGISTRY_KEY, JSON.stringify(list));
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
/** Saved backends, most-recently-used first. */
|
|
export function listInstances(): Instance[] {
|
|
return readRegistry().sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
}
|
|
|
|
/** Add (or refresh) a backend in the registry and return its record. */
|
|
export function upsertInstance(url: string, name?: string): Instance {
|
|
const baseUrl = normalizeUrl(url);
|
|
const id = instanceIdFromUrl(baseUrl);
|
|
const list = readRegistry();
|
|
const existing = list.find((i) => i.id === id);
|
|
const inst: Instance = {
|
|
id,
|
|
baseUrl,
|
|
name: name ?? existing?.name ?? defaultName(baseUrl),
|
|
lastUsedAt: Date.now(),
|
|
};
|
|
writeRegistry(
|
|
existing ? list.map((i) => (i.id === id ? inst : i)) : [...list, inst],
|
|
);
|
|
return inst;
|
|
}
|
|
|
|
/** Remove a backend and wipe every scoped key it owns. */
|
|
export function removeInstance(id: string): void {
|
|
writeRegistry(readRegistry().filter((i) => i.id !== id));
|
|
clearScope(id);
|
|
if (getActiveInstanceId() === id) {
|
|
const next = listInstances()[0];
|
|
if (next) setActiveInstanceId(next.id);
|
|
else localStorage.removeItem(ACTIVE_KEY);
|
|
}
|
|
}
|
|
|
|
export function getActiveInstanceId(): string | null {
|
|
return localStorage.getItem(ACTIVE_KEY);
|
|
}
|
|
|
|
export function setActiveInstanceId(id: string): void {
|
|
localStorage.setItem(ACTIVE_KEY, id);
|
|
}
|
|
|
|
export function getActiveInstance(): Instance | null {
|
|
const id = getActiveInstanceId();
|
|
if (!id) return null;
|
|
return readRegistry().find((i) => i.id === id) ?? null;
|
|
}
|
|
|
|
/** Build the namespaced localStorage key for a value scoped to a backend. */
|
|
export function scopedKey(
|
|
key: string,
|
|
instanceId: string | null = getActiveInstanceId(),
|
|
): string {
|
|
return `mcma:${instanceId ?? 'none'}:${key}`;
|
|
}
|
|
|
|
/**
|
|
* Per-backend key/value store. Reads/writes are no-ops when no instance is
|
|
* active, so callers never have to special-case the unconnected state.
|
|
*/
|
|
export const instanceStorage = {
|
|
get(key: string): string | null {
|
|
const id = getActiveInstanceId();
|
|
return id ? localStorage.getItem(scopedKey(key, id)) : null;
|
|
},
|
|
set(key: string, value: string): void {
|
|
const id = getActiveInstanceId();
|
|
if (!id) return;
|
|
try {
|
|
localStorage.setItem(scopedKey(key, id), value);
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
},
|
|
remove(key: string): void {
|
|
const id = getActiveInstanceId();
|
|
if (id) localStorage.removeItem(scopedKey(key, id));
|
|
},
|
|
};
|
|
|
|
function clearScope(id: string): void {
|
|
const prefix = `mcma:${id}:`;
|
|
const toRemove: string[] = [];
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const k = localStorage.key(i);
|
|
if (k && k.startsWith(prefix)) toRemove.push(k);
|
|
}
|
|
toRemove.forEach((k) => localStorage.removeItem(k));
|
|
}
|
|
|
|
/** One-time migration of the old single-backend keys into the namespaced model. */
|
|
function migrateLegacy(): void {
|
|
try {
|
|
const legacyUrl = localStorage.getItem(LEGACY_URL_KEY);
|
|
if (!legacyUrl || readRegistry().length > 0) return;
|
|
const inst = upsertInstance(legacyUrl);
|
|
const legacyAuth = localStorage.getItem(LEGACY_AUTH_KEY);
|
|
if (legacyAuth)
|
|
localStorage.setItem(scopedKey('auth', inst.id), legacyAuth);
|
|
setActiveInstanceId(inst.id);
|
|
localStorage.removeItem(LEGACY_URL_KEY);
|
|
localStorage.removeItem(LEGACY_AUTH_KEY);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
// Fold any pre-multi-instance keys into the namespaced model on first load.
|
|
// We deliberately do NOT seed an instance from the env default: before the
|
|
// user connects there is no active instance, getApiBaseUrl() falls back to the
|
|
// env default, and the connect flow registers the real backend.
|
|
if (typeof localStorage !== 'undefined') {
|
|
migrateLegacy();
|
|
}
|