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",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"dev": "next dev --turbopack",
"build": "npm run build:lib && next build",
"postinstall": "npm run ws -- -y",
"ws": "npx --yes next-ws-cli@latest patch"
},
"dependencies": {
"@codemirror/lang-go": "^6.0.1",
@@ -55,13 +56,15 @@
"ioredis": "^5.9.2",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-ws": "^2.1.16",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"recharts": "^2.15.4",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"ws": "^8.19.0"
},
"devDependencies": {
"@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";
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(
{ error: "body requires runId and moves" },
{ error: "body requires runId, moves, file and language" },
{ status: 400 },
);
@@ -27,9 +27,7 @@ export async function POST(req: NextRequest) {
{ status: 401 },
);
console.log(moves);
const run = await Core.addLiveRunMoves(liveRun.id, moves);
const run = await Core.addLiveRunEvent(liveRun.id, file, language, moves);
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);
});
}
+43 -45
View File
@@ -8,10 +8,10 @@ import { go } from "@codemirror/lang-go";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
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 { getLiveRunMoves } from "@/modules/live-run/actions";
import { LiveRun, LiveRunMove } from "@/modules/live-run/types";
import { getOrigin } from "@/modules/getOrigin";
type Language =
| "javascript"
@@ -22,7 +22,7 @@ type Language =
| "java"
| "json";
function getLanguageExtension(lang: Language) {
function getLanguageExtension(lang: Language | null) {
switch (lang) {
case "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 [rawMoves] = useAction<LiveRunMove[]>(
async () => (run ? await getLiveRunMoves(run.id) : []),
[run],
);
const [_, setLastMoveId] = useState<number>(-1);
const [moves, setMoves] = useState<LiveRunMove[]>([]);
const [movesLoading, setMovesLoading] = useState(false);
const scheduledTimeouts = useRef<NodeJS.Timeout[]>([]);
const [text, setText] = useState("");
const [language, setLanguage] = useState<Language | null>(null);
// WebSocket connection
useEffect(() => {
if (!rawMoves || rawMoves.length === 0 || movesLoading) return;
if (!run || !origin?.host) return;
setLastMoveId((prevLastMoveId) => {
const newMoves = rawMoves.filter((move) => move.moveId > prevLastMoveId);
const ws = new WebSocket(`ws://${origin.host}/api/view-run`);
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));
});
}, [rawMoves, movesLoading]);
onEvent(data);
useEffect(() => {
if (!editorView || !moves) return;
if (data.language !== language) {
setLanguage(data.language as Language);
}
if (data.text) {
setText(data.text);
}
let cancelled = false;
const executeMove = async (move: LiveRunMove) => {
return new Promise<void>((resolve) => {
data.moves.forEach((move) => {
const timeout = setTimeout(() => {
if (!editorView || cancelled) return resolve();
if (!editorView || cancelled) return;
try {
editorView.dispatch({
selection: { anchor: move.cursor },
scrollIntoView: true,
changes: move.changes,
});
resolve();
} catch {
cancelled = true;
ws.send("getText");
}
}, move.latency);
return () => clearTimeout(timeout);
scheduledTimeouts.current.push(timeout);
});
};
(async () => {
if (!moves) return;
setMovesLoading(true);
for (const move of moves) {
if (cancelled) break;
await executeMove(move);
}
setMovesLoading(false);
})();
return () => {
cancelled = true;
};
}, [moves]);
return () => ws.close();
}, [run, ]);
return (
<CodeMirror
readOnly
className="h-full w-full"
value={``}
value={text}
extensions={[
getLanguageExtension("javascript"),
getLanguageExtension(language),
EditorState.transactionFilter.of((tr) => {
if (tr.selection && tr.isUserEvent("select")) {
return [];
+8 -2
View File
@@ -4,7 +4,13 @@ import { LiveRun } from "@/modules/live-run/types";
import { Eye } from "lucide-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");
useEffect(() => {
@@ -29,7 +35,7 @@ export default function RunTimer({ run }: { run: LiveRun }) {
</p>
<p className="text-muted-foreground flex gap-1 items-center">
<Eye className="w-4 h-4" />
{run.views}
{views || run.views}
</p>
</span>
</CardContent>
+17 -5
View File
@@ -7,16 +7,18 @@ import { FileTree } from "./components/FileTree";
import { File } from "lucide-react";
import Editor from "./components/Editor";
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 ProfileCard from "./components/ProfileCard";
import { getProblem } from "@/modules/problems/actions";
import { useParams } from "next/navigation";
import { useState } from "react";
export default function Run() {
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,
]);
@@ -25,6 +27,11 @@ export default function Run() {
[run],
);
const [file, setFile] = useState<{
file: string | null;
language: string | null;
}>({ file: null, language: null });
const chatMessages: ChatMessage[] = [
{ user: "Alice", message: "Nice split!", time: "1:53" },
{ user: "Bob", message: "Watch out for the string problem!", time: "1:54" },
@@ -51,7 +58,7 @@ export default function Run() {
</div>
<div className="flex-1 animate-fade-in opacity-0 stagger-2">
{run && <RunTimer run={run} />}
{run && <RunTimer run={run} views={views} />}
</div>
<div className="flex-2">
@@ -83,10 +90,15 @@ export default function Run() {
{/* Code */}
<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">
<File size={16} /> myfile.js
<File size={16} /> {file.file}
</header>
<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>
</CardContent>
+1 -1
View File
@@ -6,7 +6,7 @@ import { signInWithGitHub } from "@/modules/account/actions";
export default function ContinueWith() {
const continueWithGithub = () => {
signInWithGitHub(window.location.origin);
signInWithGitHub();
};
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 { createServerClient, type CookieOptions } from "@supabase/ssr";
+7 -2
View File
@@ -2,15 +2,20 @@
import * as Core from "./core";
import { createClient } from "@/lib/supabase/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function signInWithGitHub(origin: string) {
export async function signInWithGitHub() {
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({
provider: "github",
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);
}
export async function getLiveRunMoves(id: string) {
return Core.getLiveRunMoves(id);
export async function getLiveRunViews(id: string): Promise<number | null> {
return Core.getLiveRunViews(id);
}
+50 -20
View File
@@ -4,7 +4,8 @@ import "server-only";
import { LiveRun, LiveRunMove } from "./types";
import { SupabaseClient } from "@supabase/supabase-js";
import { redis } from "@/lib/redis";
import { randomInt } from "crypto";
import Redis from "ioredis";
import { applyMoves } from "@/lib/move";
/** -------------------- Live Runs in Redis -------------------- */
@@ -27,6 +28,29 @@ export async function getLiveRun(id: string): Promise<LiveRun | null> {
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
*/
@@ -41,7 +65,7 @@ export async function getLiveRuns(): Promise<LiveRun[]> {
* Remove a live run from Redis
*/
export async function removeLiveRun(id: string) {
await redis.del(`liveRun:${id}`, `liveRunMoves:${id}`);
await redis.del(`liveRun:${id}`, `liveRunEvent:${id}`);
}
/** -------------------- Live Run Moves -------------------- */
@@ -49,28 +73,34 @@ export async function removeLiveRun(id: string) {
/**
* Add moves to a live run (ephemeral)
*/
export async function addLiveRunMoves(id: string, moves: LiveRunMove[]) {
const runExists = await redis.exists(`liveRun:${id}`);
if (!runExists) throw new Error("Invalid run");
export async function addLiveRunEvent(
runId: string,
file: string,
language: string,
moves: LiveRunMove[],
) {
const redis = new Redis();
const pipeline = redis.pipeline();
moves.forEach((move) => {
pipeline.rpush(`liveRunMoves:${id}`, JSON.stringify(move));
});
// Make sure the live run exists
const runExists = await redis.exists(`liveRun:${runId}`);
if (!runExists) throw "Invalid live run";
setTimeout(() => {
redis.del(`liveRunMoves:${id}`);
}, 3000);
// Publish the new moves to subscribers
await redis.publish(
`liveRunEvent:${runId}`,
JSON.stringify({ file, language, moves }),
);
await pipeline.exec();
}
// Get the last text (from Redis)
const lastText = (await redis.get(`liveRunText:${runId}`)) || "";
/**
* Get live moves for a run
*/
export async function getLiveRunMoves(id: string): Promise<LiveRunMove[]> {
const moves = await redis.lrange(`liveRunMoves:${id}`, 0, -1);
return moves.map((m) => JSON.parse(m)) as LiveRunMove[];
// Apply the new moves to get the updated text
const updatedText = applyMoves(lastText, moves);
// Store the updated text back in Redis
await redis.set(`liveRunText:${runId}`, updatedText);
return updatedText;
}
/** -------------------- Submit Run -------------------- */
+7
View File
@@ -18,3 +18,10 @@ export interface LiveRunMove {
insert: string;
};
}
export interface LiveRunEvent {
moves: LiveRunMove[];
file: string | null;
language: string | null;
text: string | null;
}