diff --git a/package-lock.json b/package-lock.json index e2bb445..5e58135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "dev-run", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@codemirror/lang-go": "^6.0.1", "@codemirror/lang-java": "^6.0.2", @@ -44,6 +45,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.94.1", + "@types/js-cookie": "^3.0.6", "@uiw/codemirror-theme-vscode": "^4.25.4", "@uiw/react-codemirror": "^4.25.4", "axios": "^1.13.5", @@ -53,6 +55,7 @@ "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "ioredis": "^5.9.2", + "js-cookie": "^3.0.5", "lucide-react": "^0.563.0", "next": "16.1.6", "next-ws": "^2.1.16", @@ -3892,6 +3895,12 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.32", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz", @@ -4927,6 +4936,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index ec0a28a..66213bb 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.94.1", + "@types/js-cookie": "^3.0.6", "@uiw/codemirror-theme-vscode": "^4.25.4", "@uiw/react-codemirror": "^4.25.4", "axios": "^1.13.5", @@ -54,6 +55,7 @@ "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "ioredis": "^5.9.2", + "js-cookie": "^3.0.5", "lucide-react": "^0.563.0", "next": "16.1.6", "next-ws": "^2.1.16", diff --git a/src/app/api/run/move/route.ts b/src/app/api/run/move/route.ts deleted file mode 100644 index c0bc9cc..0000000 --- a/src/app/api/run/move/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; -import * as Core from "@/modules/live-run/core"; -import { getSession } from "@/modules/account/core"; - -export async function POST(req: NextRequest) { - const { runId, moves, file, language } = await req.json(); - - if (!runId || !moves || file === undefined || language === undefined) - return NextResponse.json( - { error: "body requires runId, moves, file and language" }, - { status: 400 }, - ); - - const supabase = await createClient(); - - const session = await getSession(supabase); - - if (!session) - return NextResponse.json({ error: "Invalid session" }, { status: 401 }); - - const liveRun = await Core.getLiveRun(runId); - - if (!liveRun) - return NextResponse.json( - { error: "Run is not live or does not exist" }, - { status: 401 }, - ); - - const run = await Core.addLiveRunEvent(liveRun.id, file, language, moves); - - return NextResponse.json({ ok: true, run }); -} diff --git a/src/app/api/run/route.ts b/src/app/api/run/route.ts index 71fbd99..0ee1098 100644 --- a/src/app/api/run/route.ts +++ b/src/app/api/run/route.ts @@ -1,80 +1,232 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; -import * as Core from "@/modules/live-run/core"; -import { getSession } from "@/modules/account/core"; +import { WebSocketServer, WebSocket } from "ws"; import { randomUUID } from "crypto"; +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"; -export async function PUT(req: NextRequest) { - const { problem, category } = await req.json(); +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 }); +} - if (!problem || !category) - return NextResponse.json( - { error: "body requires problem and category" }, - { status: 400 }, +export function UPGRADE(client: WebSocket, server: WebSocketServer) { + client.once("message", async (raw) => { + const { + cookies, + requestId, + }: { cookies: Record; requestId: string } = JSON.parse( + raw.toString(), ); - const supabase = await createClient(); + if (!cookies) { + client.send(JSON.stringify({ ok: false, error: "Missing cookies" })); + client.close(); + return; + } - const session = await getSession(supabase); + const supabase = await createClientCookie(new Map(Object.entries(cookies))); + const session = await getSession(supabase); - if (!session) - return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + if (!session) { + client.send(JSON.stringify({ ok: false, error: "Invalid session" })); + return; + } - const runId = randomUUID(); + client.send(JSON.stringify({ requestId, ok: true })); - await Core.addLiveRun({ - problem, - category, - id: runId, - username: session[1].username, - views: 0, - start: new Date().getTime(), - runsCount: 0, + console.log(`User ${session[1].username} connected to live run`); + + client.on("message", async (raw) => { + try { + const { requestId, ...msg }: ClientMessage = JSON.parse(raw.toString()); + + switch (msg.type) { + /* ---------------- CREATE ---------------- */ + case "create": { + const { problem, category } = msg; + + if (!problem || !category) { + client.send( + JSON.stringify({ + requestId, + ok: false, + error: "problem and category required", + }), + ); + return; + } + + 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 { runId, moves, file, language } = msg; + + if ( + !runId || + !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(runId); + + 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": { + const { runId } = msg; + + if (!runId) { + client.send( + JSON.stringify({ + requestId, + ok: false, + error: "runId required", + }), + ); + return; + } + + const liveRun = await Core.getLiveRun(runId); + + 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": { + const { runId } = msg; + + if (!runId) { + client.send( + JSON.stringify({ + requestId, + ok: false, + error: "runId required", + }), + ); + return; + } + + const run = await Core.removeLiveRun(runId); + + client.send( + JSON.stringify({ + requestId, + ok: true, + type: "delete", + data: run, + }), + ); + break; + } + + default: + client.send( + JSON.stringify({ + requestId, + ok: false, + error: "Unknown message type", + }), + ); + } + } catch (err) { + console.error(err); + client.send( + JSON.stringify({ + requestId, + ok: false, + error: "Malformed message", + }), + ); + } + }); }); - return NextResponse.json({ ok: true, runId }); -} - -export async function POST(req: NextRequest) { - const { runId } = await req.json(); - - if (!runId) - return NextResponse.json({ error: "body requires runId" }, { status: 400 }); - - const supabase = await createClient(); - - const session = await getSession(supabase); - - if (!session) - return NextResponse.json({ error: "Invalid session" }, { status: 401 }); - - const liveRun = await Core.getLiveRun(runId); - - if (!liveRun) - return NextResponse.json( - { error: "Run is not live or does not exist" }, - { status: 401 }, - ); - - const run = await Core.submitRun(supabase, liveRun, session[0].id); - - return NextResponse.json({ ok: true, run }); -} - -export async function DELETE(req: NextRequest) { - const runId = req.nextUrl.searchParams.get("runId"); - - if (!runId) - return NextResponse.json({ error: "missing ?runId=" }, { status: 400 }); - - const supabase = await createClient(); - - const session = await getSession(supabase); - - if (!session) - return NextResponse.json({ error: "Invalid session" }, { status: 401 }); - - const run = await Core.removeLiveRun(runId); - - return NextResponse.json({ ok: true, run }); + client.on("close", () => { + // optional cleanup + }); } diff --git a/src/app/api/run/types.ts b/src/app/api/run/types.ts new file mode 100644 index 0000000..6208587 --- /dev/null +++ b/src/app/api/run/types.ts @@ -0,0 +1,28 @@ +import { LiveRunMove } from "@/modules/live-run/types"; + +export type ClientMessage = { requestId: string } & ( + | { + type: "create"; + problem: string; + category: "any%" | "100%"; + } + | { + type: "move"; + runId: string; + file: string; + language: string; + moves: LiveRunMove[]; + } + | { + type: "submit"; + runId: string; + } + | { + type: "delete"; + runId: string; + } +); + +export type ServerMessage = + | { ok: true; type: string; data?: any } + | { ok: false; error: string }; diff --git a/src/app/problems/page.tsx b/src/app/problems/page.tsx index e9acd28..e8b63d8 100644 --- a/src/app/problems/page.tsx +++ b/src/app/problems/page.tsx @@ -9,6 +9,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import useAction from "@/hook/use-action"; import { getProblems } from "@/modules/problems/actions"; import axios from "axios"; +import Cookies from "js-cookie"; export default function Problems() { const [problems] = useAction(getProblems); @@ -16,7 +17,7 @@ export default function Problems() { useEffect(() => { (async () => { await axios.post("http://127.0.0.1:63780/auth", { - saveCookies: document.cookie, + saveCookies: Cookies.get(), }); })().catch(() => {}); }, []); diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index 30a93dc..c8fed94 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -27,22 +27,20 @@ export async function createClient() { ); } -export async function createClientApi() { - const cookieStore = await cookies(); - +export async function createClientCookie(cookies: Map) { return createServerClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { cookies: { get(name: string) { - return cookieStore.get(name)?.value; + return cookies.get(name) || null; }, - set(name: string, value: string, options: CookieOptions) { - cookieStore.set(name, value, options); + set(name: string, value: string) { + cookies.set(name, value); }, remove(name: string, options: CookieOptions) { - cookieStore.set(name, "", options); + cookies.delete(name); }, }, },