The browser app had no tests and no way to run any: package.json shipped dev/build/preview/typecheck, and CI installed Node and pnpm but never ran anything with them. Its densest logic was module-private, so even with a runner none of it was reachable. Extracts four pure modules out of DOM-heavy ones — tree.ts (directory grouping), keys.ts (terminal escape encoding), paths.ts (completion arithmetic), html.ts (one escaper, replacing four copies). Extraction rather than `export` because attach.ts imports xterm's CSS at module level, which a node test cannot load. CatalogStore takes an injectable socket factory, scheduler and refetch, so the reducer and the backoff schedule run without a server. snapshot() now returns a copy: it returned the live object while events mutated it in place, so listeners could not diff and every event rebuilt the whole tree. 36 tests across 6 files, and a `web` CI job running typecheck, test and build. vitest is pinned to 2.x — 4.x requires vite >= 6 and this is vite 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
169 lines
4.5 KiB
TypeScript
169 lines
4.5 KiB
TypeScript
import { eventsWsUrl, listSessions } from "./api.js";
|
|
import type { CatalogEvent, Session, Uuid } from "./types.js";
|
|
|
|
export interface CatalogState {
|
|
sessions: Map<Uuid, Session>;
|
|
}
|
|
|
|
export type CatalogListener = (state: CatalogState) => void;
|
|
|
|
/** Opens the events socket. Injectable so the reducer and the backoff schedule
|
|
* can be driven without a server. */
|
|
export type SocketFactory = () => WebSocket;
|
|
|
|
/** Schedules a reconnect attempt; returns a handle `clearTimer` understands. */
|
|
export interface Scheduler {
|
|
setTimer(fn: () => void, ms: number): number;
|
|
clearTimer(handle: number): void;
|
|
}
|
|
|
|
const browserScheduler: Scheduler = {
|
|
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
clearTimer: (h) => window.clearTimeout(h),
|
|
};
|
|
|
|
/** Initial reconnect delay, doubling to `MAX_BACKOFF_MS`. */
|
|
export const INITIAL_BACKOFF_MS = 500;
|
|
export const MAX_BACKOFF_MS = 10_000;
|
|
|
|
export interface CatalogStoreOptions {
|
|
socket?: SocketFactory;
|
|
scheduler?: Scheduler;
|
|
/** Full-state refetch used when the server reports it dropped frames. */
|
|
refetch?: () => Promise<Session[]>;
|
|
}
|
|
|
|
export class CatalogStore {
|
|
private state: CatalogState = { sessions: new Map() };
|
|
private listeners = new Set<CatalogListener>();
|
|
private ws: WebSocket | null = null;
|
|
private reconnectTimer: number | null = null;
|
|
private backoffMs = INITIAL_BACKOFF_MS;
|
|
private readonly socket: SocketFactory;
|
|
private readonly scheduler: Scheduler;
|
|
private readonly refetch: () => Promise<Session[]>;
|
|
|
|
constructor(opts: CatalogStoreOptions = {}) {
|
|
this.socket = opts.socket ?? (() => new WebSocket(eventsWsUrl()));
|
|
this.scheduler = opts.scheduler ?? browserScheduler;
|
|
this.refetch = opts.refetch ?? listSessions;
|
|
}
|
|
|
|
start(): void {
|
|
this.connect();
|
|
}
|
|
|
|
/** Current reconnect delay, for tests asserting the backoff schedule. */
|
|
backoff(): number {
|
|
return this.backoffMs;
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.reconnectTimer !== null) {
|
|
this.scheduler.clearTimer(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
if (this.ws) {
|
|
this.ws.onclose = null;
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
}
|
|
|
|
subscribe(listener: CatalogListener): () => void {
|
|
this.listeners.add(listener);
|
|
listener(this.snapshot());
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
/** A copy, so a listener can diff it against the previous one. Returning the
|
|
* live object meant in-place mutation was invisible and every event forced a
|
|
* full re-render. */
|
|
snapshot(): CatalogState {
|
|
return { sessions: new Map(this.state.sessions) };
|
|
}
|
|
|
|
private connect(): void {
|
|
const ws = this.socket();
|
|
this.ws = ws;
|
|
|
|
ws.onmessage = (ev) => {
|
|
let event: CatalogEvent;
|
|
try {
|
|
event = JSON.parse(ev.data) as CatalogEvent;
|
|
} catch {
|
|
return;
|
|
}
|
|
this.apply(event);
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
this.backoffMs = INITIAL_BACKOFF_MS;
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
this.ws = null;
|
|
this.scheduleReconnect();
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectTimer !== null) return;
|
|
const delay = Math.min(this.backoffMs, MAX_BACKOFF_MS);
|
|
this.reconnectTimer = this.scheduler.setTimer(() => {
|
|
this.reconnectTimer = null;
|
|
this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
private apply(event: CatalogEvent): void {
|
|
switch (event.type) {
|
|
case "Snapshot":
|
|
this.state = {
|
|
sessions: new Map(event.sessions.map((s) => [s.id, s])),
|
|
};
|
|
break;
|
|
case "SessionAdded":
|
|
this.state.sessions.set(event.session.id, event.session);
|
|
break;
|
|
case "SessionRemoved":
|
|
this.state.sessions.delete(event.id);
|
|
break;
|
|
case "SessionUpdated":
|
|
this.state.sessions.set(event.session.id, event.session);
|
|
break;
|
|
case "Lagged":
|
|
// Re-derive from REST to avoid showing stale state.
|
|
void this.resync();
|
|
return;
|
|
}
|
|
this.emit();
|
|
}
|
|
|
|
private async resync(): Promise<void> {
|
|
try {
|
|
const sessions = await this.refetch();
|
|
this.state = {
|
|
sessions: new Map(sessions.map((s) => [s.id, s])),
|
|
};
|
|
this.emit();
|
|
} catch {
|
|
// surface in UI on next reconnect cycle; nothing else useful to do
|
|
}
|
|
}
|
|
|
|
private emit(): void {
|
|
const snap = this.snapshot();
|
|
for (const fn of this.listeners) fn(snap);
|
|
}
|
|
}
|