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([ {