Merge pull request #15 from orus-dev/websocket

Websocket #14
This commit is contained in:
2026-02-09 21:24:57 +01:00
committed by GitHub
15 changed files with 1414 additions and 101 deletions
+1163 -8
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -3,9 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"build": "next build", "build": "npm run build:lib && next build",
"start": "next start" "postinstall": "npm run ws -- -y",
"ws": "npx --yes next-ws-cli@latest patch"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-go": "^6.0.1", "@codemirror/lang-go": "^6.0.1",
@@ -55,13 +56,15 @@
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
"next-ws": "^2.1.16",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+4 -6
View File
@@ -4,11 +4,11 @@ import * as Core from "@/modules/live-run/core";
import { getSession } from "@/modules/account/core"; import { getSession } from "@/modules/account/core";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const { runId, moves } = await req.json(); const { runId, moves, file, language } = await req.json();
if (!runId || !moves) if (!runId || !moves || file === undefined || language === undefined)
return NextResponse.json( return NextResponse.json(
{ error: "body requires runId and moves" }, { error: "body requires runId, moves, file and language" },
{ status: 400 }, { status: 400 },
); );
@@ -27,9 +27,7 @@ export async function POST(req: NextRequest) {
{ status: 401 }, { status: 401 },
); );
console.log(moves); const run = await Core.addLiveRunEvent(liveRun.id, file, language, moves);
const run = await Core.addLiveRunMoves(liveRun.id, moves);
return NextResponse.json({ ok: true, run }); return NextResponse.json({ ok: true, run });
} }
+70
View File
@@ -0,0 +1,70 @@
import { WebSocketServer, WebSocket } from "ws";
import Redis from "ioredis";
import { NextRequest, NextResponse } from "next/server";
import { LiveRunEvent } from "@/modules/live-run/types";
import { getLiveRun, updateLiveRunViews } from "@/modules/live-run/core";
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) {
const subscriber = new Redis();
let runId: string;
client.once("message", async (message) => {
runId = message.toString();
await updateLiveRunViews(runId, 1);
let lastEvent: LiveRunEvent | null = null;
const sendText = async () => {
const text = await subscriber.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 }));
}
});
});
client.once("close", async () => {
if (runId) await subscriber.unsubscribe(`liveRun:${runId}`);
subscriber.quit();
await updateLiveRunViews(runId, -1);
});
client.on("error", (err) => {
console.error("WebSocket error:", err);
});
}
+49 -51
View File
@@ -8,10 +8,10 @@ import { go } from "@codemirror/lang-go";
import { json } from "@codemirror/lang-json"; import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python"; import { python } from "@codemirror/lang-python";
import { java } from "@codemirror/lang-java"; import { java } from "@codemirror/lang-java";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { LiveRun, LiveRunEvent, LiveRunMove } from "@/modules/live-run/types";
import useAction from "@/hook/use-action"; import useAction from "@/hook/use-action";
import { getLiveRunMoves } from "@/modules/live-run/actions"; import { getOrigin } from "@/modules/getOrigin";
import { LiveRun, LiveRunMove } from "@/modules/live-run/types";
type Language = type Language =
| "javascript" | "javascript"
@@ -22,7 +22,7 @@ type Language =
| "java" | "java"
| "json"; | "json";
function getLanguageExtension(lang: Language) { function getLanguageExtension(lang: Language | null) {
switch (lang) { switch (lang) {
case "javascript": case "javascript":
return javascript(); return javascript();
@@ -41,76 +41,74 @@ function getLanguageExtension(lang: Language) {
} }
} }
export default function Editor({ run }: { run: LiveRun | null | undefined }) { export default function Editor({
run,
onEvent,
}: {
run: LiveRun | null | undefined;
onEvent: (s: LiveRunEvent) => void;
}) {
const [origin] = useAction(getOrigin);
const [editorView, setEditorView] = useState<EditorView>(); const [editorView, setEditorView] = useState<EditorView>();
const [rawMoves] = useAction<LiveRunMove[]>( const scheduledTimeouts = useRef<NodeJS.Timeout[]>([]);
async () => (run ? await getLiveRunMoves(run.id) : []), const [text, setText] = useState("");
[run], const [language, setLanguage] = useState<Language | null>(null);
);
const [_, setLastMoveId] = useState<number>(-1);
const [moves, setMoves] = useState<LiveRunMove[]>([]);
const [movesLoading, setMovesLoading] = useState(false);
// WebSocket connection
useEffect(() => { useEffect(() => {
if (!rawMoves || rawMoves.length === 0 || movesLoading) return; if (!run || !origin?.host) return;
setLastMoveId((prevLastMoveId) => { const ws = new WebSocket(`ws://${origin.host}/api/view-run`);
const newMoves = rawMoves.filter((move) => move.moveId > prevLastMoveId);
if (newMoves.length === 0) return prevLastMoveId; ws.onopen = () => {
ws.send(run.id);
};
setMoves(newMoves); ws.onmessage = (m) => {
const data: LiveRunEvent = JSON.parse(m.data);
return Math.max(...newMoves.map((m) => m.moveId)); onEvent(data);
});
}, [rawMoves, movesLoading]);
useEffect(() => { if (data.language !== language) {
if (!editorView || !moves) return; setLanguage(data.language as Language);
}
let cancelled = false; if (data.text) {
setText(data.text);
}
const executeMove = async (move: LiveRunMove) => { let cancelled = false;
return new Promise<void>((resolve) => {
data.moves.forEach((move) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!editorView || cancelled) return resolve(); if (!editorView || cancelled) return;
editorView.dispatch({ try {
selection: { anchor: move.cursor }, editorView.dispatch({
scrollIntoView: true, selection: { anchor: move.cursor },
changes: move.changes, scrollIntoView: true,
}); changes: move.changes,
});
resolve(); } catch {
cancelled = true;
ws.send("getText");
}
}, move.latency); }, move.latency);
return () => clearTimeout(timeout); scheduledTimeouts.current.push(timeout);
}); });
}; };
(async () => { return () => ws.close();
if (!moves) return; }, [run, ]);
setMovesLoading(true);
for (const move of moves) {
if (cancelled) break;
await executeMove(move);
}
setMovesLoading(false);
})();
return () => {
cancelled = true;
};
}, [moves]);
return ( return (
<CodeMirror <CodeMirror
readOnly readOnly
className="h-full w-full" className="h-full w-full"
value={``} value={text}
extensions={[ extensions={[
getLanguageExtension("javascript"), getLanguageExtension(language),
EditorState.transactionFilter.of((tr) => { EditorState.transactionFilter.of((tr) => {
if (tr.selection && tr.isUserEvent("select")) { if (tr.selection && tr.isUserEvent("select")) {
return []; return [];
+8 -2
View File
@@ -4,7 +4,13 @@ import { LiveRun } from "@/modules/live-run/types";
import { Eye } from "lucide-react"; import { Eye } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function RunTimer({ run }: { run: LiveRun }) { export default function RunTimer({
run,
views,
}: {
run: LiveRun;
views: number | null;
}) {
const [time, setTime] = useState("00:00:00"); const [time, setTime] = useState("00:00:00");
useEffect(() => { useEffect(() => {
@@ -29,7 +35,7 @@ export default function RunTimer({ run }: { run: LiveRun }) {
</p> </p>
<p className="text-muted-foreground flex gap-1 items-center"> <p className="text-muted-foreground flex gap-1 items-center">
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
{run.views} {views || run.views}
</p> </p>
</span> </span>
</CardContent> </CardContent>
+17 -5
View File
@@ -7,16 +7,18 @@ import { FileTree } from "./components/FileTree";
import { File } from "lucide-react"; import { File } from "lucide-react";
import Editor from "./components/Editor"; import Editor from "./components/Editor";
import useAction, { useActionInterval } from "@/hook/use-action"; import useAction, { useActionInterval } from "@/hook/use-action";
import { getLiveRun } from "@/modules/live-run/actions"; import { getLiveRun, getLiveRunViews } from "@/modules/live-run/actions";
import ProblemCard from "@/app/problems/components/ProblemCard"; import ProblemCard from "@/app/problems/components/ProblemCard";
import ProfileCard from "./components/ProfileCard"; import ProfileCard from "./components/ProfileCard";
import { getProblem } from "@/modules/problems/actions"; import { getProblem } from "@/modules/problems/actions";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useState } from "react";
export default function Run() { export default function Run() {
const params = useParams<{ runId: string }>(); const params = useParams<{ runId: string }>();
const [run] = useActionInterval(() => getLiveRun(params.runId), 1000, [ const [run] = useAction(() => getLiveRun(params.runId), [params]);
const [views] = useActionInterval(() => getLiveRunViews(params.runId), 2500, [
params, params,
]); ]);
@@ -25,6 +27,11 @@ export default function Run() {
[run], [run],
); );
const [file, setFile] = useState<{
file: string | null;
language: string | null;
}>({ file: null, language: null });
const chatMessages: ChatMessage[] = [ const chatMessages: ChatMessage[] = [
{ user: "Alice", message: "Nice split!", time: "1:53" }, { user: "Alice", message: "Nice split!", time: "1:53" },
{ user: "Bob", message: "Watch out for the string problem!", time: "1:54" }, { user: "Bob", message: "Watch out for the string problem!", time: "1:54" },
@@ -51,7 +58,7 @@ export default function Run() {
</div> </div>
<div className="flex-1 animate-fade-in opacity-0 stagger-2"> <div className="flex-1 animate-fade-in opacity-0 stagger-2">
{run && <RunTimer run={run} />} {run && <RunTimer run={run} views={views} />}
</div> </div>
<div className="flex-2"> <div className="flex-2">
@@ -83,10 +90,15 @@ export default function Run() {
{/* Code */} {/* Code */}
<div className="flex-1 w-full max-h-svh md:max-h-full h-full pl-0 md:pl-5 flex flex-col"> <div className="flex-1 w-full max-h-svh md:max-h-full h-full pl-0 md:pl-5 flex flex-col">
<header className="text-sm font-medium text-muted-foreground flex items-center gap-1"> <header className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<File size={16} /> myfile.js <File size={16} /> {file.file}
</header> </header>
<div className="flex-1 pt-3 pb-4 md:pb-5 w-full h-full"> <div className="flex-1 pt-3 pb-4 md:pb-5 w-full h-full">
<Editor run={run} /> <Editor
run={run}
onEvent={(event) => {
setFile({ file: event.file, language: event.language });
}}
/>
</div> </div>
</div> </div>
</CardContent> </CardContent>
+1 -1
View File
@@ -6,7 +6,7 @@ import { signInWithGitHub } from "@/modules/account/actions";
export default function ContinueWith() { export default function ContinueWith() {
const continueWithGithub = () => { const continueWithGithub = () => {
signInWithGitHub(window.location.origin); signInWithGitHub();
}; };
const continueWithGoogle = () => {}; const continueWithGoogle = () => {};
+16
View File
@@ -0,0 +1,16 @@
import { LiveRunMove } from "@/modules/live-run/types";
export function applyMoves(text: string, moves: LiveRunMove[]) {
let result = text;
for (const move of moves) {
if (!move.changes) continue;
result =
result.slice(0, move.changes.from) +
move.changes.insert +
result.slice(move.changes.to);
}
return result;
}
+2
View File
@@ -1,3 +1,5 @@
"use server";
import "server-only"; import "server-only";
import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { createServerClient, type CookieOptions } from "@supabase/ssr";
+7 -2
View File
@@ -2,15 +2,20 @@
import * as Core from "./core"; import * as Core from "./core";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export async function signInWithGitHub(origin: string) { export async function signInWithGitHub() {
const supabase = await createClient(); const supabase = await createClient();
const h = await headers();
const host = h.get("host");
const protocol = h.get("x-forwarded-proto") ?? "https";
const { data, error } = await supabase.auth.signInWithOAuth({ const { data, error } = await supabase.auth.signInWithOAuth({
provider: "github", provider: "github",
options: { options: {
redirectTo: `${origin}/api/auth/callback`, redirectTo: `${protocol}://${host}/api/auth/callback`,
}, },
}); });
+11
View File
@@ -0,0 +1,11 @@
"use server";
import { headers } from "next/headers";
export async function getOrigin(): Promise<{ protocol: string; host: string }> {
const h = await headers();
const host = h.get("host") || "localhost:3000";
const protocol = h.get("x-forwarded-proto") ?? "https";
return { protocol, host };
}
+2 -2
View File
@@ -11,6 +11,6 @@ export async function getLiveRun(id: string): Promise<LiveRun | null> {
return Core.getLiveRun(id); return Core.getLiveRun(id);
} }
export async function getLiveRunMoves(id: string) { export async function getLiveRunViews(id: string): Promise<number | null> {
return Core.getLiveRunMoves(id); return Core.getLiveRunViews(id);
} }
+50 -20
View File
@@ -4,7 +4,8 @@ import "server-only";
import { LiveRun, LiveRunMove } from "./types"; import { LiveRun, LiveRunMove } from "./types";
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
import { randomInt } from "crypto"; import Redis from "ioredis";
import { applyMoves } from "@/lib/move";
/** -------------------- Live Runs in Redis -------------------- */ /** -------------------- Live Runs in Redis -------------------- */
@@ -27,6 +28,29 @@ export async function getLiveRun(id: string): Promise<LiveRun | null> {
return JSON.parse(data) as LiveRun; return JSON.parse(data) as LiveRun;
} }
/**
* Get live run views from Redis
*/
export async function getLiveRunViews(id: string): Promise<number | null> {
const run = await getLiveRun(id);
if (!run) return null;
return run.views;
}
/**
* Get live run views from Redis
*/
export async function updateLiveRunViews(
id: string,
increment: number,
): Promise<number | null> {
const run = await getLiveRun(id);
if (!run) return null;
run.views += increment;
await redis.set(`liveRun:${run.id}`, JSON.stringify(run));
return run.views;
}
/** /**
* Get all live runs * Get all live runs
*/ */
@@ -41,7 +65,7 @@ export async function getLiveRuns(): Promise<LiveRun[]> {
* Remove a live run from Redis * Remove a live run from Redis
*/ */
export async function removeLiveRun(id: string) { export async function removeLiveRun(id: string) {
await redis.del(`liveRun:${id}`, `liveRunMoves:${id}`); await redis.del(`liveRun:${id}`, `liveRunEvent:${id}`);
} }
/** -------------------- Live Run Moves -------------------- */ /** -------------------- Live Run Moves -------------------- */
@@ -49,28 +73,34 @@ export async function removeLiveRun(id: string) {
/** /**
* Add moves to a live run (ephemeral) * Add moves to a live run (ephemeral)
*/ */
export async function addLiveRunMoves(id: string, moves: LiveRunMove[]) { export async function addLiveRunEvent(
const runExists = await redis.exists(`liveRun:${id}`); runId: string,
if (!runExists) throw new Error("Invalid run"); file: string,
language: string,
moves: LiveRunMove[],
) {
const redis = new Redis();
const pipeline = redis.pipeline(); // Make sure the live run exists
moves.forEach((move) => { const runExists = await redis.exists(`liveRun:${runId}`);
pipeline.rpush(`liveRunMoves:${id}`, JSON.stringify(move)); if (!runExists) throw "Invalid live run";
});
setTimeout(() => { // Publish the new moves to subscribers
redis.del(`liveRunMoves:${id}`); await redis.publish(
}, 3000); `liveRunEvent:${runId}`,
JSON.stringify({ file, language, moves }),
);
await pipeline.exec(); // Get the last text (from Redis)
} const lastText = (await redis.get(`liveRunText:${runId}`)) || "";
/** // Apply the new moves to get the updated text
* Get live moves for a run const updatedText = applyMoves(lastText, moves);
*/
export async function getLiveRunMoves(id: string): Promise<LiveRunMove[]> { // Store the updated text back in Redis
const moves = await redis.lrange(`liveRunMoves:${id}`, 0, -1); await redis.set(`liveRunText:${runId}`, updatedText);
return moves.map((m) => JSON.parse(m)) as LiveRunMove[];
return updatedText;
} }
/** -------------------- Submit Run -------------------- */ /** -------------------- Submit Run -------------------- */
+7
View File
@@ -18,3 +18,10 @@ export interface LiveRunMove {
insert: string; insert: string;
}; };
} }
export interface LiveRunEvent {
moves: LiveRunMove[];
file: string | null;
language: string | null;
text: string | null;
}