Restructured client

This commit is contained in:
2026-02-16 00:54:27 +01:00
parent 38d53f0a6d
commit fde6a4ccb9
+96 -20
View File
@@ -2,16 +2,28 @@ import { WebSocket } from "ws";
import { LiveRunMove } from "./types"; import { LiveRunMove } from "./types";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
/* ──────────────────────────
State
────────────────────────── */
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
let connecting: Promise<WebSocket> | null = null;
let currentOrigin: string | null = null;
let cookies: Record<string, string> | undefined; let cookies: Record<string, string> | undefined;
type PendingResolver = { type PendingResolver = {
resolve: (value: any) => void; resolve: (value: any) => void;
reject: (err: any) => void; reject: (err: any) => void;
timeout: NodeJS.Timeout;
}; };
const pending = new Map<string, PendingResolver>(); const pending = new Map<string, PendingResolver>();
/* ──────────────────────────
Cookies
────────────────────────── */
export function getCookies(): Record<string, string> | undefined { export function getCookies(): Record<string, string> | undefined {
return cookies; return cookies;
} }
@@ -20,74 +32,134 @@ export function setCookies(c: Record<string, string>) {
cookies = c; cookies = c;
} }
/* ──────────────────────────
Utils
────────────────────────── */
function getWsOrigin(useLocalhost: boolean) { function getWsOrigin(useLocalhost: boolean) {
return useLocalhost return useLocalhost
? "ws://localhost:3000/api/run" ? "ws://localhost:3000/api/run"
: "wss://dev-run.selimaj.dev/api/run"; : "wss://dev-run.selimaj.dev/api/run";
} }
function rejectAllPending(err: Error) {
for (const { reject, timeout } of pending.values()) {
clearTimeout(timeout);
reject(err);
}
pending.clear();
}
/* ──────────────────────────
Socket management
────────────────────────── */
async function ensureSocket(useLocalhost: boolean): Promise<WebSocket> { async function ensureSocket(useLocalhost: boolean): Promise<WebSocket> {
const origin = getWsOrigin(useLocalhost);
if ( if (
socket && socket &&
socket.url === getWsOrigin(useLocalhost) && socket.readyState === WebSocket.OPEN &&
socket.readyState === WebSocket.OPEN currentOrigin === origin
) { ) {
return socket; return socket;
} }
if (socket && socket.url !== getWsOrigin(useLocalhost)) { if (connecting) {
socket.close(); return connecting;
} }
return new Promise((resolve, reject) => { if (socket && currentOrigin !== origin) {
const ws = new WebSocket(getWsOrigin(useLocalhost)); socket.close();
socket = null;
}
connecting = new Promise<WebSocket>((resolve, reject) => {
const ws = new WebSocket(origin);
ws.onopen = async () => { ws.onopen = async () => {
socket = ws; try {
const authCookies = getCookies();
if (!authCookies) {
throw new Error("Cookies not set");
}
console.log("Authenticating with cookies:", getCookies()); socket = ws;
currentOrigin = origin;
connecting = null;
await sendUnsafe(useLocalhost, { cookies: getCookies() }); await sendUnsafe(useLocalhost, {
type: "auth",
cookies: authCookies,
});
resolve(ws); resolve(ws);
}; } catch (err) {
ws.close();
ws.onerror = (err) => { reject(err);
reject(err); }
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data.toString()); let msg: any;
const { requestId, ok, data, error } = msg; try {
msg = JSON.parse(e.data.toString());
} catch {
console.warn("Invalid WS message:", e.data);
return;
}
const { requestId, ok, data, error } = msg;
if (!requestId) return; if (!requestId) return;
const pendingReq = pending.get(requestId); const pendingReq = pending.get(requestId);
if (!pendingReq) return; if (!pendingReq) return;
pending.delete(requestId); pending.delete(requestId);
clearTimeout(pendingReq.timeout);
ok ? pendingReq.resolve(data) : pendingReq.reject(error); ok ? pendingReq.resolve(data) : pendingReq.reject(error);
}; };
ws.onerror = (err) => {
rejectAllPending(new Error("WebSocket error"));
connecting = null;
reject(err);
};
ws.onclose = () => { ws.onclose = () => {
rejectAllPending(new Error("WebSocket closed"));
socket = null; socket = null;
currentOrigin = null;
connecting = null;
}; };
}); });
return connecting;
} }
/* ──────────────────────────
RPC helpers
────────────────────────── */
async function sendUnsafe<T>( async function sendUnsafe<T>(
useLocalhost: boolean, useLocalhost: boolean,
payload: Record<string, any>, payload: Record<string, any>,
): Promise<T> { ): Promise<T> {
if (!socket) { if (!socket || socket.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not connected"); throw new Error("WebSocket is not open");
} }
const requestId = randomUUID(); const requestId = randomUUID();
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
pending.set(requestId, { resolve, reject }); const timeout = setTimeout(() => {
pending.delete(requestId);
reject(new Error("WebSocket request timed out"));
}, 10_000);
pending.set(requestId, { resolve, reject, timeout });
socket?.send(JSON.stringify({ ...payload, requestId })); socket?.send(JSON.stringify({ ...payload, requestId }));
}); });
} }
@@ -97,9 +169,13 @@ async function send<T>(
payload: Record<string, any>, payload: Record<string, any>,
): Promise<T> { ): Promise<T> {
await ensureSocket(useLocalhost); await ensureSocket(useLocalhost);
return await sendUnsafe(useLocalhost, payload); return sendUnsafe(useLocalhost, payload);
} }
/* ──────────────────────────
Public API
────────────────────────── */
export async function addRun( export async function addRun(
useLocalhost: boolean, useLocalhost: boolean,
problem: string, problem: string,