From 0c9c86848ec0c72853bcab1fb1bfc942b927ef85 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 01:59:54 +0100 Subject: [PATCH 1/9] Slight rename in leaderboards --- src/app/leaderboard/components/LeaderboardRow.tsx | 2 +- src/app/leaderboard/page.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/leaderboard/components/LeaderboardRow.tsx b/src/app/leaderboard/components/LeaderboardRow.tsx index 8458976..4104a8e 100644 --- a/src/app/leaderboard/components/LeaderboardRow.tsx +++ b/src/app/leaderboard/components/LeaderboardRow.tsx @@ -48,7 +48,7 @@ const LeaderboardRow = ({ - {/* Wins */} + {/* World Records */} {run.wins} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index 3d18b83..0fff916 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -78,9 +78,9 @@ export default function Leaderboard() { {/* Runner / Username */} Runner - {/* Wins */} + {/* World Records */} - Wins + World Records {/* Podiums */} From bd54227a98085c32bf38c2b7b3678c1704606bc3 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 02:18:22 +0100 Subject: [PATCH 2/9] Sorted live runs --- src/app/runs/components/LiveRunRow.tsx | 2 +- src/app/runs/page.tsx | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/app/runs/components/LiveRunRow.tsx b/src/app/runs/components/LiveRunRow.tsx index 08faad2..05d4bec 100644 --- a/src/app/runs/components/LiveRunRow.tsx +++ b/src/app/runs/components/LiveRunRow.tsx @@ -21,7 +21,7 @@ export default function LiveRunRow({ const [problem] = useAction(() => getProblem(run.problem)); const now = new Date().getTime() + 1000; - const isTop = run.views > 5; + const isTop = run.views >= 5; return ( { + const aTrending = a.views >= 5; + const bTrending = b.views >= 5; + + if (aTrending && bTrending) { + // Both trending → sort by views descending, then timestamp descending + if (b.views !== a.views) return b.views - a.views; + return b.start - a.start; + } else if (aTrending) { + return -1; // a goes first + } else if (bTrending) { + return 1; // b goes first + } else { + // Neither trending → sort by timestamp descending + return b.start - a.start; + } + }); return (
@@ -26,7 +41,7 @@ export default function LiveRuns() {

Live Runs

- 42 live runs · Streaming in real time + {liveRuns?.length} live runs · Streaming in real time

From de07f634f92ecea449bc1406608c32b1f5d197a0 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 02:23:13 +0100 Subject: [PATCH 3/9] Views fix --- src/modules/live-run/core.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/live-run/core.ts b/src/modules/live-run/core.ts index 305b9b9..49c5e94 100644 --- a/src/modules/live-run/core.ts +++ b/src/modules/live-run/core.ts @@ -46,6 +46,7 @@ export async function updateLiveRunViews( ): Promise { const run = await getLiveRun(id); if (!run) return null; + if (run.views === 0 && increment < 0) return null; run.views += increment; await redis.set(`liveRun:${run.id}`, JSON.stringify(run)); return run.views; From 7ec8b9ae4d4501f3e75cfb65ab72f062b411f5ee Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 02:38:53 +0100 Subject: [PATCH 4/9] Max redis clients fix --- src/app/api/run/route.ts | 14 ++--- src/app/api/view-run/route.ts | 99 ++++++++++++++++++----------------- src/lib/redis.ts | 6 +++ 3 files changed, 64 insertions(+), 55 deletions(-) diff --git a/src/app/api/run/route.ts b/src/app/api/run/route.ts index 4d964ee..3fb1bc7 100644 --- a/src/app/api/run/route.ts +++ b/src/app/api/run/route.ts @@ -7,13 +7,6 @@ import { ClientMessage } from "./types"; import { NextRequest } from "next/server"; import { redis } from "@/lib/redis"; -export function GET(req: NextRequest) { - const headers = new Headers(); - headers.set("Connection", "Upgrade"); - headers.set("Upgrade", "websocket"); - return new Response("Upgrade Required", { status: 426, headers }); -} - export function UPGRADE(client: WebSocket, server: WebSocketServer) { client.once("message", async (raw) => { const { @@ -244,3 +237,10 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { // optional cleanup }); } + +export function GET(req: NextRequest) { + const headers = new Headers(); + headers.set("Connection", "Upgrade"); + headers.set("Upgrade", "websocket"); + return new Response("Upgrade Required", { status: 426, headers }); +} diff --git a/src/app/api/view-run/route.ts b/src/app/api/view-run/route.ts index 8686012..7d921b3 100644 --- a/src/app/api/view-run/route.ts +++ b/src/app/api/view-run/route.ts @@ -1,67 +1,63 @@ +// ws/liveRun.ts import { WebSocketServer, WebSocket } from "ws"; -import Redis from "ioredis"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { LiveRunEvent } from "@/modules/live-run/types"; import { updateLiveRunViews } from "@/modules/live-run/core"; -import { redis } from "@/lib/redis"; +import { redis, redisSubscriber } from "@/lib/redis"; -export function GET(req: NextRequest) { - const headers = new Headers(); - headers.set("Connection", "Upgrade"); - headers.set("Upgrade", "websocket"); - return new Response("Upgrade Required", { status: 426, headers }); -} +// Map of runId → Set of WebSockets +const runSubscribers: Map> = new Map(); -export function UPGRADE(client: WebSocket, server: WebSocketServer) { - const subscriber = new Redis(process.env.REDIS_URL!); +// Handle incoming Pub/Sub messages +redisSubscriber.on("message", (channel, message) => { + const runId = channel.split(":")[1]; + const sockets = runSubscribers.get(runId); + if (!sockets) return; + + const data: LiveRunEvent = JSON.parse(message); + + sockets.forEach((ws) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ ...data, text: null })); + } + }); +}); + +export async function UPGRADE(client: WebSocket, server: WebSocketServer) { let runId: string; + // Receive runId from client client.once("message", async (message) => { runId = message.toString(); + // Track view count await updateLiveRunViews(runId, 1); - let lastEvent: LiveRunEvent | null = null; + // Send initial text + const text = await redis.get(`liveRunText:${runId}`); + client.send( + JSON.stringify({ file: null, language: null, moves: [], text }), + ); - const sendText = async () => { - const text = await redis.get(`liveRunText:${runId}`); - - if (client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - file: lastEvent?.file, - language: lastEvent?.language, - moves: [], - text, - }), - ); - } - }; - - sendText(); - - client.on("message", sendText); - - subscriber.subscribe(`liveRunEvent:${runId}`, (err, count) => { - if (err) { - console.error("Failed to subscribe", err); - client.close(1011, "Redis subscription failed"); - } - }); - - subscriber.on("message", (channel, message) => { - const data: LiveRunEvent = JSON.parse(message); - - lastEvent = data; - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ ...data, text: null })); - } - }); + // Add WebSocket to subscribers + if (!runSubscribers.has(runId)) { + runSubscribers.set(runId, new Set()); + await redisSubscriber.subscribe(`liveRunEvent:${runId}`); + } + runSubscribers.get(runId)!.add(client); }); + // Handle client disconnect client.once("close", async () => { - await subscriber.unsubscribe(`liveRunEvent:${runId}`); - subscriber.quit(); + const set = runSubscribers.get(runId); + if (set) { + set.delete(client); + if (set.size === 0) { + await redisSubscriber.unsubscribe(`liveRunEvent:${runId}`); + runSubscribers.delete(runId); + } + } + await updateLiveRunViews(runId, -1); }); @@ -69,3 +65,10 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { console.error("WebSocket error:", err); }); } + +export function GET(req: NextRequest) { + const headers = new Headers(); + headers.set("Connection", "Upgrade"); + headers.set("Upgrade", "websocket"); + return new Response("Upgrade Required", { status: 426, headers }); +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts index f433e79..33321b3 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -3,6 +3,12 @@ import Redis from "ioredis"; export const redis = new Redis(process.env.REDIS_URL!); +export const redisSubscriber = new Redis(process.env.REDIS_URL!); + redis.on("error", (err) => { console.error("Redis error:", err); }); + +redisSubscriber.on("error", (err) => { + console.error("Redis subscriber error:", err); +}); From abda0b52af352485412b66e761cb2f679fdf8cdf Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 02:50:57 +0100 Subject: [PATCH 5/9] Current run management --- src/app/api/run/route.ts | 75 +++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/src/app/api/run/route.ts b/src/app/api/run/route.ts index 3fb1bc7..245352f 100644 --- a/src/app/api/run/route.ts +++ b/src/app/api/run/route.ts @@ -32,7 +32,7 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { client.send(JSON.stringify({ requestId, ok: true })); - console.log(`User ${session[1].username} connected to live run`); + let currentRun: string | undefined; client.on("message", async (raw) => { try { @@ -54,6 +54,18 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { return; } + if (currentRun) { + client.send( + JSON.stringify({ + requestId, + ok: false, + error: + "Cannot do multiple runs at a time, please delete or submit the current run", + }), + ); + return; + } + const runId = randomUUID(); await Core.addLiveRun({ @@ -79,14 +91,20 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { /* ---------------- DELETE ---------------- */ case "move": { - const { runId, moves, file, language } = msg; + const { moves, file, language } = msg; - if ( - !runId || - !moves || - file === undefined || - language === undefined - ) { + if (!currentRun) { + client.send( + JSON.stringify({ + requestId, + ok: false, + error: "No current run, please create a run first", + }), + ); + return; + } + + if (!moves || file === undefined || language === undefined) { client.send( JSON.stringify({ requestId, @@ -97,7 +115,7 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { return; } - const liveRun = await Core.getLiveRun(runId); + const liveRun = await Core.getLiveRun(currentRun); if (!liveRun) { client.send( @@ -131,20 +149,18 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { /* ---------------- SUBMIT ---------------- */ case "submit": { - const { runId } = msg; - - if (!runId) { + if (!currentRun) { client.send( JSON.stringify({ requestId, ok: false, - error: "runId required", + error: "No current run, please create a run first", }), ); return; } - const liveRun = await Core.getLiveRun(runId); + const liveRun = await Core.getLiveRun(currentRun); if (!liveRun) { client.send( @@ -172,20 +188,18 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { /* ---------------- DELETE ---------------- */ case "delete": { - const { runId } = msg; - - if (!runId) { + if (!currentRun) { client.send( JSON.stringify({ requestId, ok: false, - error: "runId required", + error: "No current run, please create a run first", }), ); return; } - const run = await Core.removeLiveRun(runId); + const run = await Core.removeLiveRun(currentRun); client.send( JSON.stringify({ @@ -199,8 +213,18 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { } case "getText": - const { runId } = msg; - const data = await redis.get(`liveRunText:${runId}`); + if (!currentRun) { + client.send( + JSON.stringify({ + requestId, + ok: false, + error: "No current run, please create a run first", + }), + ); + return; + } + + const data = await redis.get(`liveRunText:${currentRun}`); client.send( JSON.stringify({ @@ -231,10 +255,13 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { ); } }); - }); - client.on("close", () => { - // optional cleanup + client.on("close", async () => { + if (!currentRun) return; + + Core.removeLiveRun(currentRun); + await redis.del(`liveRunText:${currentRun}`); + }); }); } From cbd41d9e37c6eb1f3a8ae7fef36e156c4706d456 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 03:27:26 +0100 Subject: [PATCH 6/9] Refactored /api/run --- package-lock.json | 48 ++-- package.json | 2 +- src/app/api/run/route.ts | 423 ++++++++++++++++------------------- src/lib/supabase/server.ts | 8 +- src/modules/live-run/core.ts | 11 +- 5 files changed, 225 insertions(+), 267 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e58135..4c4d1bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@supabase/ssr": "^0.8.0", - "@supabase/supabase-js": "^2.94.1", + "@supabase/supabase-js": "^2.95.3", "@types/js-cookie": "^3.0.6", "@uiw/codemirror-theme-vscode": "^4.25.4", "@uiw/react-codemirror": "^4.25.4", @@ -3461,9 +3461,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.94.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.94.1.tgz", - "integrity": "sha512-Wt/SdmAtNNiqrcBbPlzWojLcE1bQ9OYb8PTaYF6QccFX5JeXZI0sZ01MLNE+E83UK6cK0lw4YznX0D2g08UQng==", + "version": "2.95.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.95.3.tgz", + "integrity": "sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -3473,9 +3473,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.94.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.94.1.tgz", - "integrity": "sha512-A7Bx0gnclDNZ4m8+mnO2IEEzMxtUSg7cpPEBF6Ek1LpjIQkC7vvoidiV/RuntnKX43IiVcWV1f2FsAppMagEmQ==", + "version": "2.95.3", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.95.3.tgz", + "integrity": "sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -3485,9 +3485,9 @@ } }, "node_modules/@supabase/postgrest-js": { - "version": "2.94.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.94.1.tgz", - "integrity": "sha512-N6MTghjHnMZddT48rAj8dIFgedCU97cc1ahQM74Tc+DF4UH7y2+iEfdYV3unJsylpaiWlu92Fy8Lj14Jbrmxog==", + "version": "2.95.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.95.3.tgz", + "integrity": "sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -3497,9 +3497,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.94.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.94.1.tgz", - "integrity": "sha512-Wq8olpCAGmN4y2DH2kUdlcakdzNHRCde72BFS8zK5ub46bBeSUoE9DqrfeNFWKaF2gCE/cmK8aTUTorZD9jdtQ==", + "version": "2.95.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.95.3.tgz", + "integrity": "sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw==", "license": "MIT", "dependencies": { "@types/phoenix": "^1.6.6", @@ -3524,9 +3524,9 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.94.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.94.1.tgz", - "integrity": "sha512-/Mi18LGyrugPwtfqETfAqEGcBQotY/7IMsTGYgEFdqr8cQq280BVQWjN2wI9KibWtshPp0Ryvil5Uzd5YfM7kA==", + "version": "2.95.3", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.95.3.tgz", + "integrity": "sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -3537,16 +3537,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.94.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.94.1.tgz", - "integrity": "sha512-87vOY8n3WHB3m+a/KeySj07djOQVuRA5qgX5E7db1eDkaZ1of5M+3t/tv6eYYy4BfqxuHMZuCe5uVrO/oyvoow==", + "version": "2.95.3", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.95.3.tgz", + "integrity": "sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.94.1", - "@supabase/functions-js": "2.94.1", - "@supabase/postgrest-js": "2.94.1", - "@supabase/realtime-js": "2.94.1", - "@supabase/storage-js": "2.94.1" + "@supabase/auth-js": "2.95.3", + "@supabase/functions-js": "2.95.3", + "@supabase/postgrest-js": "2.95.3", + "@supabase/realtime-js": "2.95.3", + "@supabase/storage-js": "2.95.3" }, "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index 66213bb..70eedf2 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@supabase/ssr": "^0.8.0", - "@supabase/supabase-js": "^2.94.1", + "@supabase/supabase-js": "^2.95.3", "@types/js-cookie": "^3.0.6", "@uiw/codemirror-theme-vscode": "^4.25.4", "@uiw/react-codemirror": "^4.25.4", diff --git a/src/app/api/run/route.ts b/src/app/api/run/route.ts index 245352f..b39d329 100644 --- a/src/app/api/run/route.ts +++ b/src/app/api/run/route.ts @@ -4,264 +4,221 @@ import { createClientCookie } from "@/lib/supabase/server"; import { getSession } from "@/modules/account/core"; import * as Core from "@/modules/live-run/core"; import { ClientMessage } from "./types"; -import { NextRequest } from "next/server"; import { redis } from "@/lib/redis"; +import { User } from "@supabase/supabase-js"; +import { UserProfile } from "@/modules/user/types"; +import { NextRequest } from "next/server"; export function UPGRADE(client: WebSocket, server: WebSocketServer) { - client.once("message", async (raw) => { - const { - cookies, - requestId, - }: { cookies: Record; requestId: string } = JSON.parse( - raw.toString(), - ); + let authenticated = false; + let session: [User, UserProfile] | null = null; + let currentRun: string | undefined; - if (!cookies) { - client.send(JSON.stringify({ ok: false, error: "Missing cookies" })); + const closeClient = (reason?: string) => { + if (reason) console.warn("Closing client:", reason); + try { client.close(); - return; - } + // force close after 1s if still open + setTimeout(() => { + if (client.readyState !== WebSocket.CLOSED) client.terminate(); + }, 1000); + } catch {} + }; - const supabase = await createClientCookie(new Map(Object.entries(cookies))); - const session = await getSession(supabase); + client.on("message", async (raw) => { + let requestId: string | undefined; - if (!session) { - client.send(JSON.stringify({ ok: false, error: "Invalid session" })); - return; - } + try { + const msg = JSON.parse(raw.toString()); + requestId = msg.requestId; - client.send(JSON.stringify({ requestId, ok: true })); + // ---------------- AUTHENTICATION ---------------- + if (!authenticated) { + const { cookies } = msg as { cookies?: Record }; + if (!cookies) return closeClient("Missing cookies"); - let currentRun: string | undefined; + // Await the session + const supabase = await createClientCookie( + new Map(Object.entries(cookies)), + ); + session = await getSession(supabase); - client.on("message", async (raw) => { - try { - const { requestId, ...msg }: ClientMessage = JSON.parse(raw.toString()); + if (!session) return closeClient("Invalid session"); - switch (msg.type) { - /* ---------------- CREATE ---------------- */ - case "create": { - const { problem, category } = msg; + authenticated = true; + return client.send(JSON.stringify({ requestId, ok: true })); + } - if (!problem || !category) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "problem and category required", - }), - ); - return; - } + if (!session) return closeClient("Session unexpectedly null"); - if (currentRun) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: - "Cannot do multiple runs at a time, please delete or submit the current run", - }), - ); - return; - } + // ---------------- POST-AUTH MESSAGE HANDLING ---------------- + const { type, ...payload } = msg as ClientMessage; - const runId = randomUUID(); - - await Core.addLiveRun({ - id: runId, - problem, - category, - username: session[1].username, - views: 0, - start: Date.now(), - runsCount: 0, - }); - - client.send( - JSON.stringify({ - requestId, - ok: true, - type: "create", - data: { runId }, - }), - ); - break; - } - - /* ---------------- DELETE ---------------- */ - case "move": { - const { moves, file, language } = msg; - - if (!currentRun) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "No current run, please create a run first", - }), - ); - return; - } - - if (!moves || file === undefined || language === undefined) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "body requires runId, moves, file and language", - }), - ); - return; - } - - const liveRun = await Core.getLiveRun(currentRun); - - if (!liveRun) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "Run is not live or does not exist", - }), - ); - return; - } - - const run = await Core.addLiveRunEvent( - liveRun.id, - file, - language, - moves, - ); - - client.send( - JSON.stringify({ - requestId, - ok: true, - type: "move", - data: run, - }), - ); - - break; - } - - /* ---------------- SUBMIT ---------------- */ - case "submit": { - if (!currentRun) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "No current run, please create a run first", - }), - ); - return; - } - - const liveRun = await Core.getLiveRun(currentRun); - - if (!liveRun) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "Run is not live or does not exist", - }), - ); - return; - } - - const run = await Core.submitRun(supabase, liveRun, session[0].id); - - client.send( - JSON.stringify({ - requestId, - ok: true, - type: "submit", - data: run, - }), - ); - break; - } - - /* ---------------- DELETE ---------------- */ - case "delete": { - if (!currentRun) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "No current run, please create a run first", - }), - ); - return; - } - - const run = await Core.removeLiveRun(currentRun); - - client.send( - JSON.stringify({ - requestId, - ok: true, - type: "delete", - data: run, - }), - ); - break; - } - - case "getText": - if (!currentRun) { - client.send( - JSON.stringify({ - requestId, - ok: false, - error: "No current run, please create a run first", - }), - ); - return; - } - - const data = await redis.get(`liveRunText:${currentRun}`); - - client.send( - JSON.stringify({ - requestId, - ok: true, - data, - }), - ); - break; - - default: - client.send( + switch (type) { + case "create": { + const { problem, category } = payload as any; + if (!problem || !category) { + return client.send( JSON.stringify({ requestId, ok: false, - error: "Unknown message type", + error: "problem and category required", }), ); + } + if (currentRun) { + return client.send( + JSON.stringify({ + requestId, + ok: false, + error: + "Cannot do multiple runs, delete/submit current run first", + }), + ); + } + + const runId = randomUUID(); + await Core.addLiveRun({ + id: runId, + problem, + category, + username: session[1].username, + views: 0, + start: Date.now(), + runsCount: 0, + }); + + currentRun = runId; + + return client.send( + JSON.stringify({ + requestId, + ok: true, + type: "create", + data: { runId }, + }), + ); } - } catch (err) { - console.error(err); + + case "move": { + if (!currentRun) + return client.send( + JSON.stringify({ requestId, ok: false, error: "No current run" }), + ); + const { moves, file, language } = payload as any; + if (!moves || file === undefined || language === undefined) { + return client.send( + JSON.stringify({ + requestId, + ok: false, + error: "moves, file, and language required", + }), + ); + } + + const liveRun = await Core.getLiveRun(currentRun); + if (!liveRun) + return client.send( + JSON.stringify({ requestId, ok: false, error: "Run not live" }), + ); + + const run = await Core.addLiveRunEvent( + liveRun.id, + file, + language, + moves, + ); + + return client.send( + JSON.stringify({ requestId, ok: true, type: "move", data: run }), + ); + } + + case "submit": { + if (!currentRun) + return client.send( + JSON.stringify({ requestId, ok: false, error: "No current run" }), + ); + + const liveRun = await Core.getLiveRun(currentRun); + if (!liveRun) + return client.send( + JSON.stringify({ requestId, ok: false, error: "Run not live" }), + ); + + const run = await Core.submitRun(liveRun, session[0].id); + currentRun = undefined; + + return client.send( + JSON.stringify({ requestId, ok: true, type: "submit", data: run }), + ); + } + + case "delete": { + if (!currentRun) + return client.send( + JSON.stringify({ requestId, ok: false, error: "No current run" }), + ); + + await Core.removeLiveRun(currentRun); + const deletedRun = currentRun; + currentRun = undefined; + + return client.send( + JSON.stringify({ + requestId, + ok: true, + type: "delete", + data: { id: deletedRun }, + }), + ); + } + + case "getText": { + if (!currentRun) + return client.send( + JSON.stringify({ requestId, ok: false, error: "No current run" }), + ); + + const data = await redis.get(`liveRunText:${currentRun}`); + return client.send(JSON.stringify({ requestId, ok: true, data })); + } + + default: + return client.send( + JSON.stringify({ + requestId, + ok: false, + error: "Unknown message type", + }), + ); + } + } catch (err) { + console.error(err); + if (requestId) { client.send( - JSON.stringify({ - requestId, - ok: false, - error: "Malformed message", - }), + JSON.stringify({ requestId, ok: false, error: "Malformed message" }), ); } - }); + closeClient("Malformed message"); + } + }); - client.on("close", async () => { - if (!currentRun) return; + client.on("close", async () => { + console.log("WebSocket closed, cleaning up run:", currentRun); + if (currentRun) { + try { + await Core.removeLiveRun(currentRun); + } catch (err) { + console.error("Error cleaning up run on close:", err); + } + currentRun = undefined; + } + }); - Core.removeLiveRun(currentRun); - await redis.del(`liveRunText:${currentRun}`); - }); + client.on("error", (err) => { + console.error("WebSocket error:", err); + closeClient("WebSocket error"); }); } diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index c8fed94..dd20470 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -1,9 +1,8 @@ -"use server"; - import "server-only"; import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { cookies } from "next/headers"; +import { createClient as createSimpleClient } from "@supabase/supabase-js"; export async function createClient() { const cookieStore = await cookies(); @@ -46,3 +45,8 @@ export async function createClientCookie(cookies: Map) { }, ); } + +export const supabaseApiClient = createSimpleClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY!, +); diff --git a/src/modules/live-run/core.ts b/src/modules/live-run/core.ts index 49c5e94..4837008 100644 --- a/src/modules/live-run/core.ts +++ b/src/modules/live-run/core.ts @@ -6,6 +6,7 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { redis } from "@/lib/redis"; import Redis from "ioredis"; import { applyMoves } from "@/lib/move"; +import { supabaseApiClient } from "@/lib/supabase/server"; /** -------------------- Live Runs in Redis -------------------- */ @@ -66,7 +67,7 @@ export async function getLiveRuns(): Promise { * Remove a live run from Redis */ export async function removeLiveRun(id: string) { - await redis.del(`liveRun:${id}`, `liveRunEvent:${id}`); + await redis.del(`liveRun:${id}`, `liveRunEvent:${id}`, `liveRunText:${id}`); } /** -------------------- Live Run Moves -------------------- */ @@ -108,15 +109,11 @@ export async function addLiveRunEvent( * Submit a run to Supabase (persistent storage) * and remove it from Redis */ -export async function submitRun( - supabase: SupabaseClient, - run: LiveRun, - user_id: string, -) { +export async function submitRun(run: LiveRun, user_id: string) { const now = new Date(); const durationMs = now.getTime() - run.start; - const { data, error } = await supabase + const { data, error } = await supabaseApiClient .from("runs") .insert([ { From 49ed1a5f36c5cf642c441ebb04327a9ae87036f4 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 03:34:30 +0100 Subject: [PATCH 7/9] ping pong --- src/app/api/run/route.ts | 69 +++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/src/app/api/run/route.ts b/src/app/api/run/route.ts index b39d329..92c901f 100644 --- a/src/app/api/run/route.ts +++ b/src/app/api/run/route.ts @@ -14,17 +14,43 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { let session: [User, UserProfile] | null = null; let currentRun: string | undefined; + // Heartbeat + let isAlive = true; + const heartbeatInterval = 15000; + const heartbeat = setInterval(() => { + if (!isAlive) { + console.log("Client did not respond to ping, terminating"); + return closeClient("Heartbeat timeout"); + } + isAlive = false; + if (client.readyState === WebSocket.OPEN) client.ping(); + }, heartbeatInterval); + + client.on("pong", () => { + isAlive = true; + }); + const closeClient = (reason?: string) => { if (reason) console.warn("Closing client:", reason); try { client.close(); - // force close after 1s if still open setTimeout(() => { if (client.readyState !== WebSocket.CLOSED) client.terminate(); }, 1000); } catch {} }; + const cleanupRun = async () => { + if (currentRun) { + try { + await Core.removeLiveRun(currentRun); + } catch (err) { + console.error("Error cleaning up run:", err); + } + currentRun = undefined; + } + }; + client.on("message", async (raw) => { let requestId: string | undefined; @@ -32,17 +58,15 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { const msg = JSON.parse(raw.toString()); requestId = msg.requestId; - // ---------------- AUTHENTICATION ---------------- + // AUTHENTICATION if (!authenticated) { const { cookies } = msg as { cookies?: Record }; if (!cookies) return closeClient("Missing cookies"); - // Await the session const supabase = await createClientCookie( new Map(Object.entries(cookies)), ); session = await getSession(supabase); - if (!session) return closeClient("Invalid session"); authenticated = true; @@ -51,13 +75,13 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { if (!session) return closeClient("Session unexpectedly null"); - // ---------------- POST-AUTH MESSAGE HANDLING ---------------- + // POST-AUTH MESSAGE HANDLING const { type, ...payload } = msg as ClientMessage; switch (type) { case "create": { const { problem, category } = payload as any; - if (!problem || !category) { + if (!problem || !category) return client.send( JSON.stringify({ requestId, @@ -65,8 +89,7 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { error: "problem and category required", }), ); - } - if (currentRun) { + if (currentRun) return client.send( JSON.stringify({ requestId, @@ -75,7 +98,6 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { "Cannot do multiple runs, delete/submit current run first", }), ); - } const runId = randomUUID(); await Core.addLiveRun({ @@ -89,7 +111,6 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { }); currentRun = runId; - return client.send( JSON.stringify({ requestId, @@ -105,8 +126,9 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { return client.send( JSON.stringify({ requestId, ok: false, error: "No current run" }), ); + const { moves, file, language } = payload as any; - if (!moves || file === undefined || language === undefined) { + if (!moves || file === undefined || language === undefined) return client.send( JSON.stringify({ requestId, @@ -114,7 +136,6 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { error: "moves, file, and language required", }), ); - } const liveRun = await Core.getLiveRun(currentRun); if (!liveRun) @@ -128,7 +149,6 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { language, moves, ); - return client.send( JSON.stringify({ requestId, ok: true, type: "move", data: run }), ); @@ -148,7 +168,6 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { const run = await Core.submitRun(liveRun, session[0].id); currentRun = undefined; - return client.send( JSON.stringify({ requestId, ok: true, type: "submit", data: run }), ); @@ -160,16 +179,13 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { JSON.stringify({ requestId, ok: false, error: "No current run" }), ); - await Core.removeLiveRun(currentRun); - const deletedRun = currentRun; - currentRun = undefined; - + await cleanupRun(); return client.send( JSON.stringify({ requestId, ok: true, type: "delete", - data: { id: deletedRun }, + data: { id: currentRun }, }), ); } @@ -195,29 +211,22 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { } } catch (err) { console.error(err); - if (requestId) { + if (requestId) client.send( JSON.stringify({ requestId, ok: false, error: "Malformed message" }), ); - } closeClient("Malformed message"); } }); client.on("close", async () => { - console.log("WebSocket closed, cleaning up run:", currentRun); - if (currentRun) { - try { - await Core.removeLiveRun(currentRun); - } catch (err) { - console.error("Error cleaning up run on close:", err); - } - currentRun = undefined; - } + clearInterval(heartbeat); + await cleanupRun(); }); client.on("error", (err) => { console.error("WebSocket error:", err); + clearInterval(heartbeat); closeClient("WebSocket error"); }); } From 75c09dfc58d11fa85fe5d0b47658a9ba0788837b Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 03:39:54 +0100 Subject: [PATCH 8/9] Fixed use server structure on core --- src/modules/account/core.ts | 3 +-- src/modules/leaderboard/core.ts | 3 +-- src/modules/live-run/core.ts | 5 +---- src/modules/problems/core.ts | 3 +-- src/modules/user/core.ts | 2 -- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/modules/account/core.ts b/src/modules/account/core.ts index d250696..920650a 100644 --- a/src/modules/account/core.ts +++ b/src/modules/account/core.ts @@ -1,7 +1,6 @@ -"use server"; +import "server-only"; import { SupabaseClient, User } from "@supabase/supabase-js"; -import "server-only"; import { UserProfile } from "../user/types"; export async function signInWithGitHub(supabase: SupabaseClient) { diff --git a/src/modules/leaderboard/core.ts b/src/modules/leaderboard/core.ts index d981b6c..a4b0634 100644 --- a/src/modules/leaderboard/core.ts +++ b/src/modules/leaderboard/core.ts @@ -1,7 +1,6 @@ -"use server"; +import "server-only"; import { SupabaseClient } from "@supabase/supabase-js"; -import "server-only"; import { LeaderboardUser } from "./types"; export async function getGlobalLeaderboard( diff --git a/src/modules/live-run/core.ts b/src/modules/live-run/core.ts index 4837008..396753b 100644 --- a/src/modules/live-run/core.ts +++ b/src/modules/live-run/core.ts @@ -1,10 +1,7 @@ -"use server"; - import "server-only"; + import { LiveRun, LiveRunMove } from "./types"; -import { SupabaseClient } from "@supabase/supabase-js"; import { redis } from "@/lib/redis"; -import Redis from "ioredis"; import { applyMoves } from "@/lib/move"; import { supabaseApiClient } from "@/lib/supabase/server"; diff --git a/src/modules/problems/core.ts b/src/modules/problems/core.ts index a0c2346..db1550e 100644 --- a/src/modules/problems/core.ts +++ b/src/modules/problems/core.ts @@ -1,6 +1,5 @@ -"use server"; - import "server-only"; + import { Problem, ProblemDefinition } from "./types"; const problems: ProblemDefinition[] = [ diff --git a/src/modules/user/core.ts b/src/modules/user/core.ts index a59f71d..3a1088a 100644 --- a/src/modules/user/core.ts +++ b/src/modules/user/core.ts @@ -1,3 +1 @@ -"use server"; - import "server-only"; From 92909980922ee2b208312af5ee3f83e1d148db3f Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Tue, 17 Feb 2026 16:03:23 +0100 Subject: [PATCH 9/9] Small thing --- src/app/api/run/route.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/api/run/route.ts b/src/app/api/run/route.ts index 92c901f..b7449af 100644 --- a/src/app/api/run/route.ts +++ b/src/app/api/run/route.ts @@ -16,7 +16,6 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { // Heartbeat let isAlive = true; - const heartbeatInterval = 15000; const heartbeat = setInterval(() => { if (!isAlive) { console.log("Client did not respond to ping, terminating"); @@ -24,7 +23,7 @@ export function UPGRADE(client: WebSocket, server: WebSocketServer) { } isAlive = false; if (client.readyState === WebSocket.OPEN) client.ping(); - }, heartbeatInterval); + }, 15000); client.on("pong", () => { isAlive = true;