From 4520eaf8615529a8b7748ff0424542b5d46ca6ba Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Sun, 15 Feb 2026 23:55:54 +0100 Subject: [PATCH 1/5] Testing websocket client --- package-lock.json | 34 ++++++++++++- package.json | 4 +- src/client.ts | 127 ++++++++++++++++++++++++++++++---------------- 3 files changed, 120 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 126fedc..6a475a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,12 @@ "@types/cors": "^2.8.19", "@types/diff-match-patch": "^1.0.36", "@types/express": "^5.0.6", + "@types/ws": "^8.18.1", "axios": "^1.13.5", "cors": "^2.8.6", "diff-match-patch": "^1.0.5", - "express": "^5.2.1" + "express": "^5.2.1", + "ws": "^8.19.0" }, "devDependencies": { "@types/mocha": "^10.0.6", @@ -407,6 +409,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -4140,6 +4151,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 14f640e..3a4bd61 100644 --- a/package.json +++ b/package.json @@ -91,9 +91,11 @@ "@types/cors": "^2.8.19", "@types/diff-match-patch": "^1.0.36", "@types/express": "^5.0.6", + "@types/ws": "^8.18.1", "axios": "^1.13.5", "cors": "^2.8.6", "diff-match-patch": "^1.0.5", - "express": "^5.2.1" + "express": "^5.2.1", + "ws": "^8.19.0" } } diff --git a/src/client.ts b/src/client.ts index a2116bb..f7803ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,16 +1,78 @@ -import axios from "axios"; +import { WebSocket } from "ws"; import { LiveRunMove } from "./types"; +import { randomUUID } from "crypto"; -var cookies: string | undefined; +let socket: WebSocket | null = null; +let cookies: string | undefined; + +type PendingResolver = { + resolve: (value: any) => void; + reject: (err: any) => void; +}; + +const pending = new Map(); export function getCookies(): string | undefined { return cookies; } + export function setCookies(c: string) { cookies = c; } -function getOrigin(useLocalhost: boolean) { - return useLocalhost ? "http://localhost:3000" : "https://dev-run.selimaj.dev"; + +function getWsOrigin(useLocalhost: boolean) { + return useLocalhost + ? "ws://localhost:3000/api/run" + : "wss://dev-run.selimaj.dev/api/run"; +} + +function ensureSocket(useLocalhost: boolean): WebSocket { + if (socket && socket.readyState === WebSocket.OPEN) { + return socket; + } + + socket = new WebSocket(getWsOrigin(useLocalhost)); + + socket.onmessage = (e) => { + const msg = JSON.parse(e.data.toString()); + const { requestId, ok, data, error } = msg; + + if (!requestId) return; + + const pendingReq = pending.get(requestId); + if (!pendingReq) return; + + pending.delete(requestId); + + ok ? pendingReq.resolve(data) : pendingReq.reject(error); + }; + + socket.onclose = () => { + socket = null; + }; + + return socket; +} + +function send( + useLocalhost: boolean, + payload: Record, +): Promise { + return new Promise((resolve, reject) => { + const ws = ensureSocket(useLocalhost); + + const requestId = randomUUID(); + + pending.set(requestId, { resolve, reject }); + + ws.send( + JSON.stringify({ + ...payload, + requestId, + cookies, // optional: only needed if you authenticate via cookies manually + }), + ); + }); } export async function addRun( @@ -18,34 +80,20 @@ export async function addRun( problem: string, mode: string, ): Promise { - return ( - await axios.put( - getOrigin(useLocalhost) + "/api/run", - { - problem, - category: mode, - }, - { - headers: { - Cookie: cookies, - }, - }, - ) - ).data.runId; + const data = await send<{ runId: string }>(useLocalhost, { + type: "create", + problem, + category: mode, + }); + + return data.runId; } export async function submitRun(useLocalhost: boolean, runId: string) { - await axios.post( - getOrigin(useLocalhost) + "/api/run", - { - runId, - }, - { - headers: { - Cookie: cookies, - }, - }, - ); + await send(useLocalhost, { + type: "submit", + runId, + }); } export async function addRunMoves( @@ -55,18 +103,11 @@ export async function addRunMoves( language: string | null, moves: LiveRunMove[], ) { - await axios.post( - getOrigin(useLocalhost) + "/api/run/move", - { - runId, - file, - moves, - language, - }, - { - headers: { - Cookie: cookies, - }, - }, - ); + await send(useLocalhost, { + type: "move", + runId, + file, + language, + moves, + }); } From f3b780c63762e47db47df734fbbfe3599b3fddd9 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 16 Feb 2026 00:44:54 +0100 Subject: [PATCH 2/5] Fixed cookie problems --- src/client.ts | 87 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/src/client.ts b/src/client.ts index f7803ba..4d402c2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,7 +3,7 @@ import { LiveRunMove } from "./types"; import { randomUUID } from "crypto"; let socket: WebSocket | null = null; -let cookies: string | undefined; +let cookies: Record | undefined; type PendingResolver = { resolve: (value: any) => void; @@ -12,11 +12,11 @@ type PendingResolver = { const pending = new Map(); -export function getCookies(): string | undefined { +export function getCookies(): Record | undefined { return cookies; } -export function setCookies(c: string) { +export function setCookies(c: Record) { cookies = c; } @@ -26,55 +26,80 @@ function getWsOrigin(useLocalhost: boolean) { : "wss://dev-run.selimaj.dev/api/run"; } -function ensureSocket(useLocalhost: boolean): WebSocket { - if (socket && socket.readyState === WebSocket.OPEN) { +async function ensureSocket(useLocalhost: boolean): Promise { + if ( + socket && + socket.url === getWsOrigin(useLocalhost) && + socket.readyState === WebSocket.OPEN + ) { return socket; } - socket = new WebSocket(getWsOrigin(useLocalhost)); + if (socket && socket.url !== getWsOrigin(useLocalhost)) { + socket.close(); + } - socket.onmessage = (e) => { - const msg = JSON.parse(e.data.toString()); - const { requestId, ok, data, error } = msg; + return new Promise((resolve, reject) => { + const ws = new WebSocket(getWsOrigin(useLocalhost)); - if (!requestId) return; + ws.onopen = () => { + socket = ws; - const pendingReq = pending.get(requestId); - if (!pendingReq) return; + console.log("Authenticating with cookies:", getCookies()); - pending.delete(requestId); + sendUnsafe(useLocalhost, { cookies: getCookies() }); - ok ? pendingReq.resolve(data) : pendingReq.reject(error); - }; + resolve(ws); + }; - socket.onclose = () => { - socket = null; - }; + ws.onerror = (err) => { + reject(err); + }; - return socket; + ws.onmessage = (e) => { + const msg = JSON.parse(e.data.toString()); + const { requestId, ok, data, error } = msg; + + if (!requestId) return; + + const pendingReq = pending.get(requestId); + if (!pendingReq) return; + + pending.delete(requestId); + + ok ? pendingReq.resolve(data) : pendingReq.reject(error); + }; + + ws.onclose = () => { + socket = null; + }; + }); } -function send( +async function sendUnsafe( useLocalhost: boolean, payload: Record, ): Promise { - return new Promise((resolve, reject) => { - const ws = ensureSocket(useLocalhost); + if (!socket) { + throw new Error("WebSocket is not connected"); + } - const requestId = randomUUID(); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { pending.set(requestId, { resolve, reject }); - - ws.send( - JSON.stringify({ - ...payload, - requestId, - cookies, // optional: only needed if you authenticate via cookies manually - }), - ); + socket?.send(JSON.stringify({ ...payload, requestId })); }); } +async function send( + useLocalhost: boolean, + payload: Record, +): Promise { + await ensureSocket(useLocalhost); + return await sendUnsafe(useLocalhost, payload); +} + export async function addRun( useLocalhost: boolean, problem: string, From 38d53f0a6def0dc50fb4e56a8d365d9603436450 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 16 Feb 2026 00:49:01 +0100 Subject: [PATCH 3/5] Fixed small issue --- src/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4d402c2..234f29c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -42,12 +42,12 @@ async function ensureSocket(useLocalhost: boolean): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(getWsOrigin(useLocalhost)); - ws.onopen = () => { + ws.onopen = async () => { socket = ws; console.log("Authenticating with cookies:", getCookies()); - sendUnsafe(useLocalhost, { cookies: getCookies() }); + await sendUnsafe(useLocalhost, { cookies: getCookies() }); resolve(ws); }; From fde6a4ccb99f3d675a2d796e7e157c522d435028 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 16 Feb 2026 00:54:27 +0100 Subject: [PATCH 4/5] Restructured client --- src/client.ts | 116 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 20 deletions(-) diff --git a/src/client.ts b/src/client.ts index 234f29c..41db992 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,16 +2,28 @@ import { WebSocket } from "ws"; import { LiveRunMove } from "./types"; import { randomUUID } from "crypto"; +/* ────────────────────────── + State +────────────────────────── */ + let socket: WebSocket | null = null; +let connecting: Promise | null = null; +let currentOrigin: string | null = null; + let cookies: Record | undefined; type PendingResolver = { resolve: (value: any) => void; reject: (err: any) => void; + timeout: NodeJS.Timeout; }; const pending = new Map(); +/* ────────────────────────── + Cookies +────────────────────────── */ + export function getCookies(): Record | undefined { return cookies; } @@ -20,74 +32,134 @@ export function setCookies(c: Record) { cookies = c; } +/* ────────────────────────── + Utils +────────────────────────── */ + function getWsOrigin(useLocalhost: boolean) { return useLocalhost ? "ws://localhost:3000/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 { + const origin = getWsOrigin(useLocalhost); + if ( socket && - socket.url === getWsOrigin(useLocalhost) && - socket.readyState === WebSocket.OPEN + socket.readyState === WebSocket.OPEN && + currentOrigin === origin ) { return socket; } - if (socket && socket.url !== getWsOrigin(useLocalhost)) { - socket.close(); + if (connecting) { + return connecting; } - return new Promise((resolve, reject) => { - const ws = new WebSocket(getWsOrigin(useLocalhost)); + if (socket && currentOrigin !== origin) { + socket.close(); + socket = null; + } + + connecting = new Promise((resolve, reject) => { + const ws = new WebSocket(origin); 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); - }; - - ws.onerror = (err) => { - reject(err); + resolve(ws); + } catch (err) { + ws.close(); + reject(err); + } }; ws.onmessage = (e) => { - const msg = JSON.parse(e.data.toString()); - const { requestId, ok, data, error } = msg; + let msg: any; + 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; const pendingReq = pending.get(requestId); if (!pendingReq) return; pending.delete(requestId); + clearTimeout(pendingReq.timeout); ok ? pendingReq.resolve(data) : pendingReq.reject(error); }; + ws.onerror = (err) => { + rejectAllPending(new Error("WebSocket error")); + connecting = null; + reject(err); + }; + ws.onclose = () => { + rejectAllPending(new Error("WebSocket closed")); socket = null; + currentOrigin = null; + connecting = null; }; }); + + return connecting; } +/* ────────────────────────── + RPC helpers +────────────────────────── */ + async function sendUnsafe( useLocalhost: boolean, payload: Record, ): Promise { - if (!socket) { - throw new Error("WebSocket is not connected"); + if (!socket || socket.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not open"); } const requestId = randomUUID(); return new Promise((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 })); }); } @@ -97,9 +169,13 @@ async function send( payload: Record, ): Promise { await ensureSocket(useLocalhost); - return await sendUnsafe(useLocalhost, payload); + return sendUnsafe(useLocalhost, payload); } +/* ────────────────────────── + Public API +────────────────────────── */ + export async function addRun( useLocalhost: boolean, problem: string, From 62c49404cb690f4e64766c04c604d5e234eba42a Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 16 Feb 2026 01:09:26 +0100 Subject: [PATCH 5/5] Send cursor position every second --- src/move.ts | 77 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/src/move.ts b/src/move.ts index bb4ac63..dbedef9 100644 --- a/src/move.ts +++ b/src/move.ts @@ -11,6 +11,7 @@ export function startMonitoring(useLocalhost: boolean, runId: string) { let moves: LiveRunMove[] = []; let lastText = ""; + let lastCursorOffset: number | undefined; let lastEventTime = Date.now(); let idleTimeout: NodeJS.Timeout | undefined; let isSending = false; @@ -18,7 +19,9 @@ export function startMonitoring(useLocalhost: boolean, runId: string) { const IDLE_MS = 350; if (vscode.window.activeTextEditor) { - lastText = vscode.window.activeTextEditor.document.getText(); + const editor = vscode.window.activeTextEditor; + lastText = editor.document.getText(); + lastCursorOffset = editor.document.offsetAt(editor.selection.active); } async function flush(file: string | null, language: string | null) { @@ -42,41 +45,72 @@ export function startMonitoring(useLocalhost: boolean, runId: string) { return latency; } + // --- Existing text change interval --- const readInterval = setInterval(() => { const editor = vscode.window.activeTextEditor; if (!editor) return; const newText = editor.document.getText(); - if (newText === lastText) return; + if (newText !== lastText) { + let latency = getLatency(); - let latency = getLatency(); + const diffs = dmp.diff_main(lastText, newText); + dmp.diff_cleanupEfficiency(diffs); - const diffs = dmp.diff_main(lastText, newText); - dmp.diff_cleanupEfficiency(diffs); + const changes = diffsToChanges(diffs); - const changes = diffsToChanges(diffs); + lastText = newText; - lastText = newText; + for (const change of changes) { + moves.push({ + moveId: moveId++, + latency, + cursor: editor.document.offsetAt(editor.selection.active), + changes: change, + }); + } + + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout( + () => + flush( + path.basename(editor?.document.fileName || "") || null, + editor?.document.languageId || null, + ), + IDLE_MS, + ); + } + }, 500); + + // --- New cursor tracking interval --- + const cursorInterval = setInterval(() => { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + + const cursorOffset = editor.document.offsetAt(editor.selection.active); + + if (cursorOffset !== lastCursorOffset) { + const latency = getLatency(); - for (const change of changes) { moves.push({ moveId: moveId++, latency, - cursor: editor.document.offsetAt(editor.selection.active), - changes: change, + cursor: cursorOffset, }); - } - if (idleTimeout) clearTimeout(idleTimeout); - idleTimeout = setTimeout( - () => - flush( - path.basename(editor?.document.fileName || "") || null, - editor?.document.languageId || null, - ), - IDLE_MS, - ); - }, 500); + lastCursorOffset = cursorOffset; + + if (idleTimeout) clearTimeout(idleTimeout); + idleTimeout = setTimeout( + () => + flush( + path.basename(editor.document.fileName || "") || null, + editor.document.languageId || null, + ), + IDLE_MS, + ); + } + }, 1000); // every second vscode.window.onDidChangeActiveTextEditor((editor) => { let latency = getLatency(); @@ -99,6 +133,7 @@ export function startMonitoring(useLocalhost: boolean, runId: string) { return () => { clearInterval(readInterval); + clearInterval(cursorInterval); if (idleTimeout) clearTimeout(idleTimeout); flush( path.basename(vscode.window.activeTextEditor?.document.fileName || "") ||