feat(parcel-sync): server-side eTerra session + auto-connect on UAT typing
- Add session-store.ts: global singleton for shared eTerra session state with job tracking (registerJob/unregisterJob/getRunningJobs) - Add GET/POST /api/eterra/session: connect/disconnect with job-running guard - Export routes: credential fallback chain (body > session > env vars), register/unregister active jobs for disconnect protection - Login route: also creates server-side session - ConnectionPill: session-aware display with job count, no credential form - Auto-connect: triggers on first UAT keystroke via autoConnectAttempted ref - Session polling: all clients poll GET /api/eterra/session every 30s - Multi-client: any browser sees shared connection state
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Server-side eTerra session store (global singleton).
|
||||
*
|
||||
* Holds one shared session for the whole app. Multiple browser clients
|
||||
* see the same connection state. The EterraClient already caches sessions
|
||||
* by credential hash, so we just store which credentials are active +
|
||||
* metadata (who connected, when, running jobs).
|
||||
*/
|
||||
|
||||
import { getProgress } from "./progress-store";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type EterraSession = {
|
||||
/** Username used to connect */
|
||||
username: string;
|
||||
/** Password (kept in memory only, never sent to clients) */
|
||||
password: string;
|
||||
/** ISO timestamp of connection */
|
||||
connectedAt: string;
|
||||
/** Running job IDs — tracked so we can warn before disconnect */
|
||||
activeJobs: Set<string>;
|
||||
};
|
||||
|
||||
export type SessionStatus = {
|
||||
connected: boolean;
|
||||
username?: string;
|
||||
connectedAt?: string;
|
||||
/** How many jobs are currently running */
|
||||
activeJobCount: number;
|
||||
/** First running job's phase (for UI hint) */
|
||||
activeJobPhase?: string;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Global store */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const g = globalThis as { __eterraSessionStore?: EterraSession | null };
|
||||
|
||||
function getSession(): EterraSession | null {
|
||||
return g.__eterraSessionStore ?? null;
|
||||
}
|
||||
|
||||
function setSession(session: EterraSession | null) {
|
||||
g.__eterraSessionStore = session;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function createSession(username: string, password: string): void {
|
||||
setSession({
|
||||
username,
|
||||
password,
|
||||
connectedAt: new Date().toISOString(),
|
||||
activeJobs: new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
export function destroySession(): { destroyed: boolean; reason?: string } {
|
||||
const session = getSession();
|
||||
if (!session) return { destroyed: true };
|
||||
|
||||
// Check for running jobs
|
||||
const running = getRunningJobs(session);
|
||||
if (running.length > 0) {
|
||||
return {
|
||||
destroyed: false,
|
||||
reason: `Există ${running.length} job(uri) active. Așteaptă finalizarea lor înainte de deconectare.`,
|
||||
};
|
||||
}
|
||||
|
||||
setSession(null);
|
||||
return { destroyed: true };
|
||||
}
|
||||
|
||||
export function forceDestroySession(): void {
|
||||
setSession(null);
|
||||
}
|
||||
|
||||
export function getSessionCredentials(): {
|
||||
username: string;
|
||||
password: string;
|
||||
} | null {
|
||||
const session = getSession();
|
||||
if (!session) return null;
|
||||
return { username: session.username, password: session.password };
|
||||
}
|
||||
|
||||
export function registerJob(jobId: string): void {
|
||||
const session = getSession();
|
||||
if (session) session.activeJobs.add(jobId);
|
||||
}
|
||||
|
||||
export function unregisterJob(jobId: string): void {
|
||||
const session = getSession();
|
||||
if (session) session.activeJobs.delete(jobId);
|
||||
}
|
||||
|
||||
export function getSessionStatus(): SessionStatus {
|
||||
const session = getSession();
|
||||
if (!session) {
|
||||
return { connected: false, activeJobCount: 0 };
|
||||
}
|
||||
|
||||
const running = getRunningJobs(session);
|
||||
|
||||
// Find one running job's phase for a UI hint
|
||||
let activeJobPhase: string | undefined;
|
||||
for (const jid of running) {
|
||||
const p = getProgress(jid);
|
||||
if (p?.phase) {
|
||||
activeJobPhase = p.phase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
username: session.username,
|
||||
connectedAt: session.connectedAt,
|
||||
activeJobCount: running.length,
|
||||
activeJobPhase,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getRunningJobs(session: EterraSession): string[] {
|
||||
const running: string[] = [];
|
||||
for (const jid of session.activeJobs) {
|
||||
const p = getProgress(jid);
|
||||
// If progress exists and is still running, count it
|
||||
if (p && p.status === "running") {
|
||||
running.push(jid);
|
||||
} else {
|
||||
// Clean up finished/unknown jobs
|
||||
session.activeJobs.delete(jid);
|
||||
}
|
||||
}
|
||||
return running;
|
||||
}
|
||||
Reference in New Issue
Block a user