diff --git a/.gitignore b/.gitignore index f4b95e2..7eeb1d2 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ config.yaml past/ # Private interview template content -frontend/src/util/templateContent.ts \ No newline at end of file +frontend/src/util/reactTemplateContent.ts \ No newline at end of file diff --git a/Dockerfile.runner b/Dockerfile.runner new file mode 100644 index 0000000..6437d46 --- /dev/null +++ b/Dockerfile.runner @@ -0,0 +1,26 @@ +# Build stage +FROM golang:1.25-alpine AS build + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o runner ./cmd/runner + +# Production stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates docker-cli && \ + ln -s /usr/bin/docker /usr/local/bin/docker + +WORKDIR /app + +COPY --from=build /app/runner . +COPY --from=build /app/config ./config +COPY --from=build /app/docker ./docker + +EXPOSE 0 + +CMD ["./runner"] diff --git a/cmd/runner/main.go b/cmd/runner/main.go new file mode 100644 index 0000000..4cbf5a9 --- /dev/null +++ b/cmd/runner/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/redis/go-redis/v9" + + "goderpad/config" + "goderpad/execution" + "goderpad/models" + "goderpad/redisclient" + "goderpad/services" +) + +const runnerGroup = "runners" + +func runnerConsumerName() string { + hostname, _ := os.Hostname() + return "runner-" + hostname +} + +func main() { + if err := config.Load("config/config.yml"); err != nil { + log.Fatalf("failed to load config: %v", err) + } + + if err := redisclient.Init(); err != nil { + log.Fatalf("failed to connect to redis: %v", err) + } + defer redisclient.Close() + + // Build sandbox Docker images before accepting jobs + if config.GetEnableCodeExecution() { + execution.BuildImages() + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + rdb := redisclient.GetClient() + consumer := runnerConsumerName() + + // Create consumer group for the jobs stream + err := rdb.XGroupCreateMkStream(ctx, services.JobsStream, runnerGroup, "0").Err() + if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" { + log.Printf("warning: could not create jobs consumer group: %v", err) + } + + log.Printf("code runner started (consumer=%s), waiting for jobs...", consumer) + + for { + select { + case <-ctx.Done(): + log.Println("runner shutting down") + return + default: + } + + streams, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{ + Group: runnerGroup, + Consumer: consumer, + Streams: []string{services.JobsStream, ">"}, + Count: 1, + Block: 5 * time.Second, + }).Result() + + if err != nil { + if err == redis.Nil || ctx.Err() != nil { + continue + } + log.Printf("error reading jobs stream: %v", err) + time.Sleep(1 * time.Second) + continue + } + + for _, stream := range streams { + for _, msg := range stream.Messages { + processJob(ctx, rdb, msg) + } + } + } +} + +func processJob(ctx context.Context, rdb *redis.Client, msg redis.XMessage) { + data, ok := msg.Values["data"].(string) + if !ok { + log.Printf("invalid job message: %s", msg.ID) + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + return + } + + var job models.ExecuteJob + if err := json.Unmarshal([]byte(data), &job); err != nil { + log.Printf("failed to unmarshal job: %v", err) + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + return + } + + log.Printf("executing job %s: %s for room %s", job.JobID, job.Language, job.RoomID) + + result := models.ExecuteResult{ + JobID: job.JobID, + RoomID: job.RoomID, + UserID: job.UserID, + } + + execResult, err := execution.RunCode(job.Language, job.Code) + if err != nil { + result.Stdout = "" + result.Stderr = err.Error() + result.Code = -1 + } else { + result.Stdout = execResult.Stdout + result.Stderr = execResult.Stderr + result.Code = execResult.Code + } + + // Publish result back to Redis + resultData, err := json.Marshal(result) + if err != nil { + log.Printf("failed to marshal result for job %s: %v", job.JobID, err) + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + return + } + + if err := rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: services.ResultsStream, + MaxLen: 1000, + Approx: true, + Values: map[string]interface{}{ + "data": string(resultData), + }, + }).Err(); err != nil { + log.Printf("failed to publish result for job %s: %v", job.JobID, err) + } + + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + log.Printf("completed job %s (exit code: %d)", job.JobID, result.Code) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index b89cef6..21b3aeb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,16 +1,21 @@ package main import ( + "context" "log" + "os/signal" "strconv" + "syscall" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" "goderpad/config" + "goderpad/execution" "goderpad/handlers" "goderpad/metrics" + "goderpad/redisclient" "goderpad/services" ) @@ -36,8 +41,10 @@ func main() { }) }) + r.POST("/execute", handlers.ExecuteHandler) r.POST("/createRoom", handlers.CreateRoomHandler) r.POST("/joinRoom", handlers.JoinRoomHandler) + r.POST("/switchLanguage", handlers.SwitchLanguageHandler) r.GET("/getRoomName/:roomID", handlers.GetRoomNameHandler) r.GET("/past/:roomID", handlers.GetDocumentSaveHandler) r.GET("/validateKey", handlers.ValidateKeyHandler) @@ -48,6 +55,19 @@ func main() { go services.DeleteRoomSaves() + if config.GetEnableCodeExecution() { + go execution.BuildImages() + + if err := redisclient.Init(); err != nil { + log.Fatalf("Failed to connect to Redis: %v", err) + } + defer redisclient.Close() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go services.StartResultListener(ctx) + } + r.Run(":" + config.GetPort()) } diff --git a/config/config.example.yml b/config/config.example.yml index 5b98312..9df7319 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,6 +1,13 @@ server: port: YOUR_PORT_HERE api_key: YOUR_API_KEY_HERE + enable_code_execution: false + docker_binary_path: "" # leave empty to use "docker" from $PATH, or set full path e.g. /usr/local/bin/docker allowed_origins: - "http://localhost:7777" - - "http://localhost:5173" \ No newline at end of file + - "http://localhost:5173" + +redis: + addr: "localhost:6379" + password: "" + db: 0 \ No newline at end of file diff --git a/config/config.go b/config/config.go index 5bff060..668634c 100644 --- a/config/config.go +++ b/config/config.go @@ -9,12 +9,21 @@ import ( type Config struct { Server ServerConfig `yaml:"server"` + Redis RedisConfig `yaml:"redis"` +} + +type RedisConfig struct { + Addr string `yaml:"addr"` + Password string `yaml:"password"` + DB int `yaml:"db"` } type ServerConfig struct { - Port string `yaml:"port"` - APIKey string `yaml:"api_key"` - AllowedOrigins []string `yaml:"allowed_origins"` + Port string `yaml:"port"` + APIKey string `yaml:"api_key"` + AllowedOrigins []string `yaml:"allowed_origins"` + EnableCodeExecution bool `yaml:"enable_code_execution"` + DockerBinaryPath string `yaml:"docker_binary_path"` } var AppConfig Config @@ -45,3 +54,32 @@ func GetAPIKey() string { func GetAllowedOrigins() []string { return AppConfig.Server.AllowedOrigins } + +func GetEnableCodeExecution() bool { + return AppConfig.Server.EnableCodeExecution +} + +func GetRedisAddr() string { + if env := os.Getenv("REDIS_ADDR"); env != "" { + return env + } + if AppConfig.Redis.Addr != "" { + return AppConfig.Redis.Addr + } + return "localhost:6379" +} + +func GetRedisPassword() string { + return AppConfig.Redis.Password +} + +func GetRedisDB() int { + return AppConfig.Redis.DB +} + +func GetDockerBinaryPath() string { + if AppConfig.Server.DockerBinaryPath != "" { + return AppConfig.Server.DockerBinaryPath + } + return "docker" +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 36b42b4..9d328ce 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,12 @@ services: + goderpad-redis: + image: redis:7-alpine + container_name: goderpad-redis + ports: + - "6379:6379" + profiles: + - code-execution + goderpad-backend: build: context: . @@ -6,9 +14,25 @@ services: container_name: goderpad-backend ports: - "7778:7778" + environment: + - REDIS_ADDR=goderpad-redis:6379 volumes: - ./past:/app/past + goderpad-runner: + build: + context: . + dockerfile: Dockerfile.runner + container_name: goderpad-runner + environment: + - REDIS_ADDR=goderpad-redis:6379 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - goderpad-redis + profiles: + - code-execution + goderpad-frontend: build: context: ./frontend @@ -24,7 +48,7 @@ services: environment: - VITE_API_URL=http://localhost:7778 - VITE_WS_URL=ws://localhost:7778 - + goderpad-nginx: image: nginx:alpine volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 74c4588..e70d344 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,34 @@ services: + goderpad-redis: + image: redis:7-alpine + container_name: goderpad-redis + profiles: + - code-execution + goderpad-backend: build: context: . dockerfile: Dockerfile.backend container_name: goderpad-backend + environment: + - REDIS_ADDR=goderpad-redis:6379 volumes: - ./past:/app/past + goderpad-runner: + build: + context: . + dockerfile: Dockerfile.runner + container_name: goderpad-runner + environment: + - REDIS_ADDR=goderpad-redis:6379 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - goderpad-redis + profiles: + - code-execution + goderpad-nginx: build: context: . diff --git a/docker/cpp.Dockerfile b/docker/cpp.Dockerfile new file mode 100644 index 0000000..be5fe9b --- /dev/null +++ b/docker/cpp.Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:3.20 +RUN apk add --no-cache g++ diff --git a/docker/java.Dockerfile b/docker/java.Dockerfile new file mode 100644 index 0000000..48418f0 --- /dev/null +++ b/docker/java.Dockerfile @@ -0,0 +1 @@ +FROM eclipse-temurin:21-jdk-alpine diff --git a/docker/javascript.Dockerfile b/docker/javascript.Dockerfile new file mode 100644 index 0000000..f0e004a --- /dev/null +++ b/docker/javascript.Dockerfile @@ -0,0 +1 @@ +FROM node:20-alpine diff --git a/docker/python.Dockerfile b/docker/python.Dockerfile new file mode 100644 index 0000000..21047e8 --- /dev/null +++ b/docker/python.Dockerfile @@ -0,0 +1 @@ +FROM python:3.12-alpine diff --git a/execution/execution.go b/execution/execution.go new file mode 100644 index 0000000..869ad19 --- /dev/null +++ b/execution/execution.go @@ -0,0 +1,126 @@ +package execution + +import ( + "bytes" + "context" + "fmt" + "log" + "os/exec" + "strings" + "time" + + "goderpad/config" +) + +const ExecutionTimeout = 20 * time.Second + +type LangConfig struct { + Image string + Dockerfile string + Filename string + Command string +} + +var LanguageConfigs = map[string]LangConfig{ + "python": { + Image: "goderpad-python", + Dockerfile: "docker/python.Dockerfile", + Filename: "main.py", + Command: "cat > /tmp/main.py && python3 /tmp/main.py", + }, + "javascript": { + Image: "goderpad-javascript", + Dockerfile: "docker/javascript.Dockerfile", + Filename: "main.js", + Command: "cat > /tmp/main.js && node /tmp/main.js", + }, + "c++": { + Image: "goderpad-cpp", + Dockerfile: "docker/cpp.Dockerfile", + Filename: "main.cpp", + Command: "cat > /tmp/main.cpp && g++ -o /tmp/main /tmp/main.cpp && /tmp/main", + }, + "java": { + Image: "goderpad-java", + Dockerfile: "docker/java.Dockerfile", + Filename: "Main.java", + Command: "cat > /tmp/Main.java && javac -d /tmp /tmp/Main.java && java -cp /tmp Main", + }, +} + +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Code int `json:"code"` +} + +// BuildImages builds each language sandbox image from its Dockerfile. +func BuildImages() { + for lang, cfg := range LanguageConfigs { + log.Printf("building execution image for %s...", lang) + cmd := exec.Command(config.GetDockerBinaryPath(), "build", "-t", cfg.Image, "-f", cfg.Dockerfile, ".") + if out, err := cmd.CombinedOutput(); err != nil { + log.Printf("error building %s image: %v\n%s", lang, err, out) + } else { + log.Printf("built execution image for %s", lang) + } + } +} + +// RunCode executes user code in a sandboxed Docker container. +// Code is piped via stdin to avoid volume mounts, which don't work +// when the runner itself is inside a container (Docker-in-Docker). +func RunCode(language, code string) (*Result, error) { + cfg, ok := LanguageConfigs[language] + if !ok { + return nil, fmt.Errorf("unsupported language: %s", language) + } + + ctx, cancel := context.WithTimeout(context.Background(), ExecutionTimeout) + defer cancel() + + args := []string{ + "run", "--rm", "-i", + "--network=none", + "--memory=256m", + "--memory-swap=256m", + "--cpus=0.25", + "--pids-limit=64", + "--read-only", + "--tmpfs=/tmp:exec,size=32m", + cfg.Image, + "sh", "-c", cfg.Command, + } + + cmd := exec.CommandContext(ctx, config.GetDockerBinaryPath(), args...) + cmd.Stdin = strings.NewReader(code) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + if ctx.Err() == context.DeadlineExceeded { + return &Result{ + Stdout: stdout.String(), + Stderr: "execution timed out (20s limit)", + Code: -1, + }, nil + } + + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("docker unavailable — is it installed and running? (%v)", err) + } + } + + return &Result{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Code: exitCode, + }, nil +} diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index aa5e896..8c9d2ac 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,6 +1,19 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:7778'; -export async function createRoom(userId: string, name: string, roomName: string, initialCode?: string) { +export async function executeCode(language: string, code: string) { + try { + const response = await fetch(`${API_URL}/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language, code }), + }); + return await response.json(); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'failed to reach execution service' }; + } +} + +export async function createRoom(userId: string, name: string, roomName: string, language: string, initialCode?: string) { try { const response = await fetch(`${API_URL}/createRoom`, { method: 'POST', @@ -11,6 +24,7 @@ export async function createRoom(userId: string, name: string, roomName: string, userId, name, roomName, + language, ...(initialCode !== undefined && { initialCode }), }), }); @@ -45,6 +59,24 @@ export async function joinRoom(userId: string, name: string, roomId: string) { } } +export async function switchLanguage(roomId: string, language: string) { + try { + const response = await fetch(`${API_URL}/switchLanguage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ roomId, language }), + }); + return await response.json(); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Error switching language' + }; + } +} + export async function getRoomName(roomId: string) { try { const response = await fetch(`${API_URL}/getRoomName/${roomId}`); diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index b5d2c12..f3734f1 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,53 +1,75 @@ import React, { useState, useContext, useEffect } from 'react'; import { createRoom, validateApiKey } from '../../api/api'; import { useNavigate } from 'react-router-dom'; -import { DarkModeContext } from '../../App'; -import { UserContext } from '../../App'; +import { DarkModeContext, UserContext } from '../../App'; import Popup from '../popup/Popup'; +import { TEMPLATES, DEFAULT_CODE as DEFAULT_REACT_CODE } from '../../util/reactTemplateContent'; +import { type InterviewType } from '../room/CodeEditor'; + +interface TemplateCard { + name: string; + description: string; + code: string; + interviewType: InterviewType; +} + +const REACT_CARD: TemplateCard = { + name: 'react interview', + description: 'build and run a react component in real time', + code: DEFAULT_REACT_CODE, + interviewType: 'react', +}; + +const LEETCODE_CARD: TemplateCard = { + name: 'leetcode interview', + description: 'write code in python, c++, java, or javascript', + code: `def main():\n print("Hello, World!")\n\nmain()`, + interviewType: 'leetcode', +}; + +// Non-standard templates require an API key — exclude 'standard' since REACT_CARD already covers it +const EXTRA_TEMPLATES: TemplateCard[] = TEMPLATES + .filter(t => t.name !== 'standard') + .map(t => ({ ...t, interviewType: 'react' as InterviewType })); function HomePage() { const { isDark } = useContext(DarkModeContext); const [userName, setUserName] = useState(''); const [roomName, setRoomName] = useState(''); const [showPopup, setShowPopup] = useState(false); + const [showTemplateModal, setShowTemplateModal] = useState(false); const [showApiKeyModal, setShowApiKeyModal] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(''); const [apiKeyError, setApiKeyError] = useState(''); const [isValidating, setIsValidating] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); const navigate = useNavigate(); const { userId } = useContext(UserContext); - const handleCreateRoom = async () => { - let response; - if (roomName === '') { - response = await createRoom(userId, userName, `${userName}'s sce interview`); - } else { - response = await createRoom(userId, userName, roomName); - } - if (response.ok) { - const roomId = response.data.roomId; - const expiry = new Date().getTime() + (24 * 60 * 60 * 1000); // 24 hours - const data = JSON.stringify({ userName, expiry }); - localStorage.setItem(`goderpad-cookie-${roomId}`, data); - navigate(`/${roomId}`); - } else { - setShowPopup(true); - } - }; - useEffect(() => { - if (!showApiKeyModal) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setShowApiKeyModal(false); + if (e.key === 'Escape') { + if (showApiKeyModal) setShowApiKeyModal(false); + else if (showTemplateModal) setShowTemplateModal(false); + } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [showApiKeyModal]); + }, [showApiKeyModal, showTemplateModal]); - const handleTemplateButtonClick = () => { - setApiKeyInput(''); - setApiKeyError(''); - setShowApiKeyModal(true); + const handleSelectTemplate = async (template: TemplateCard) => { + const finalRoomName = roomName || template.name; + const language = template.interviewType === 'leetcode' ? 'python' : 'react'; + const response = await createRoom(userId, userName, finalRoomName, language, template.code); + if (response.ok) { + const roomId = response.data.roomId; + const expiry = new Date().getTime() + (24 * 60 * 60 * 1000); + localStorage.setItem(`goderpad-cookie-${roomId}`, JSON.stringify({ userName, expiry })); + navigate(`/${roomId}`, { state: { interviewType: template.interviewType } }); + } else { + setShowTemplateModal(false); + setShowPopup(true); + } }; const handleApiKeySubmit = async (e: React.FormEvent) => { @@ -62,19 +84,25 @@ function HomePage() { return; } sessionStorage.setItem('api_key', apiKeyInput); - navigate('/templates', { state: { userName, roomName, userId } }); + setIsAuthenticated(true); + setShowApiKeyModal(false); }; + const templates: TemplateCard[] = [ + REACT_CARD, + LEETCODE_CARD, + ...(isAuthenticated ? EXTRA_TEMPLATES : []), + ]; + return (<> { - setShowPopup(false); - }} + onClickButton={() => setShowPopup(false)} /> -
+ +

welcome to goderpad

sce's interview platform

@@ -88,16 +116,12 @@ function HomePage() { type='text' value={userName} onChange={(e) => setUserName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && userName.trim()) { - handleCreateRoom(); - } - }} + onKeyDown={(e) => { if (e.key === 'Enter' && userName.trim()) setShowTemplateModal(true); }} placeholder='enter your name' className={`px-5 py-4 text-lg rounded-lg focus:outline-none focus:border-blue-500 ${isDark - ? 'bg-slate-800 border border-slate-700 text-white' - : 'bg-white border border-gray-300 text-gray-900' - }`} + ? 'bg-slate-800 border border-slate-700 text-white' + : 'bg-white border border-gray-300 text-gray-900' + }`} />
@@ -110,64 +134,120 @@ function HomePage() { type='text' value={roomName} onChange={(e) => setRoomName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && userName.trim()) { - handleCreateRoom(); - } - }} + onKeyDown={(e) => { if (e.key === 'Enter' && userName.trim()) setShowTemplateModal(true); }} placeholder='name your room' className={`px-5 py-4 text-lg rounded-lg focus:outline-none focus:border-blue-500 ${isDark - ? 'bg-slate-800 border border-slate-700 text-white' - : 'bg-white border border-gray-300 text-gray-900' - }`} + ? 'bg-slate-800 border border-slate-700 text-white' + : 'bg-white border border-gray-300 text-gray-900' + }`} />
- -
+ {/* Template selection modal */} + {showTemplateModal && ( +
setShowTemplateModal(false)} + > +
e.stopPropagation()} + className={`relative rounded-2xl shadow-2xl p-8 w-full max-w-3xl mx-4 max-h-[85vh] overflow-y-auto ${isDark ? 'bg-slate-800' : 'bg-white'}`} + > +
+

choose a template

+ +
+ +
2 ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2'}`}> + {templates.map((template) => ( + + ))} +
+ + {!isAuthenticated && ( +
+ +
+ )} +
+
+ )} + + {/* API key modal */} {showApiKeyModal && (
setShowApiKeyModal(false)}>
e.stopPropagation()} className={`rounded-xl shadow-2xl p-8 max-w-sm w-full mx-4 ${isDark ? 'bg-slate-800' : 'bg-white'}`}>

Enter API Key

An API key is required to access interview templates.

{ setApiKeyInput(e.target.value); setApiKeyError(''); }} - placeholder="API key" + placeholder='API key' className={`w-full px-4 py-2 rounded-lg border outline-none mb-2 ${isDark ? 'bg-slate-700 border-slate-600 text-white placeholder-gray-400' : 'bg-gray-50 border-gray-300 text-gray-900'}`} autoFocus /> -

{apiKeyError}

-
+

{apiKeyError}

+
diff --git a/frontend/src/components/room/CodeEditor.tsx b/frontend/src/components/room/CodeEditor.tsx index 86f3fb7..fd17189 100644 --- a/frontend/src/components/room/CodeEditor.tsx +++ b/frontend/src/components/room/CodeEditor.tsx @@ -2,11 +2,33 @@ import Editor from '@monaco-editor/react'; import { useState, useRef, useContext, useEffect, useCallback } from 'react'; import { DarkModeContext, UserContext } from '../../App'; import { SandpackProvider, SandpackPreview } from '@codesandbox/sandpack-react'; +import { switchLanguage } from '../../api/api'; + +type Language = 'react' | 'javascript' | 'python' | 'cpp' | 'java'; +export type InterviewType = 'react' | 'leetcode'; + +const LANGUAGE_OPTIONS: { value: Language; label: string; monacoLang: string; sandpackTemplate?: 'react', execLang?: string }[] = [ + { value: 'react', label: 'React', monacoLang: 'javascript', sandpackTemplate: 'react' }, + { value: 'javascript', label: 'JavaScript', monacoLang: 'javascript' , execLang: 'javascript'}, + { value: 'python', label: 'Python', monacoLang: 'python' , execLang: 'python'}, + { value: 'cpp', label: 'C++', monacoLang: 'cpp' , execLang: 'c++'}, + { value: 'java', label: 'Java', monacoLang: 'java' , execLang: 'java'}, +]; + +interface RunOutput { + stdout: string; + stderr: string; + code: number | null; +} + +const LEETCODE_LANGUAGES: Language[] = ['python', 'cpp', 'java', 'javascript']; interface CodeEditorProps { code: string; setCode: (code: string) => void; ws: WebSocket | null; + roomId: string; + interviewType: InterviewType; users: Array<{ userId: string; userName: string; @@ -23,9 +45,14 @@ interface CodeEditorProps { }>; } -function CodeEditor({ code, setCode, ws, users }: CodeEditorProps) { +function CodeEditor({ code, setCode, ws, roomId, interviewType, users }: CodeEditorProps) { const { isDark } = useContext(DarkModeContext); const { userId } = useContext(UserContext); + const [language, setLanguage] = useState(interviewType === 'leetcode' ? 'python' : 'react'); + const [langDropdownOpen, setLangDropdownOpen] = useState(false); + const [runOutput, setRunOutput] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [runError, setRunError] = useState(null); const editorRef = useRef(null); const monacoRef = useRef(null); const wsRef = useRef(ws); @@ -347,13 +374,120 @@ function CodeEditor({ code, setCode, ws, users }: CodeEditorProps) { }; }, [users, userId, visibleLabels]); + // Listen for execute_result messages on the WebSocket + useEffect(() => { + if (!ws) return; + const handler = (event: MessageEvent) => { + const message = JSON.parse(event.data); + if (message.type === 'execute_result') { + setIsRunning(false); + const payload = message.payload; + if (payload.stderr && !payload.stdout && payload.code !== 0) { + setRunError(payload.stderr); + } else { + setRunOutput({ + stdout: payload.stdout, + stderr: payload.stderr, + code: payload.code, + }); + } + } + }; + ws.addEventListener('message', handler); + return () => ws.removeEventListener('message', handler); + }, [ws]); + + const runCode = () => { + if (!selectedLang.execLang) return; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + setIsRunning(true); + setRunOutput(null); + setRunError(null); + ws.send(JSON.stringify({ + userId, + type: 'execute_request', + payload: { + language: selectedLang.execLang, + code: code, + } + })); + }; + + const availableLanguages = interviewType === 'leetcode' + ? LEETCODE_LANGUAGES.map(lang => LANGUAGE_OPTIONS.find(l => l.value === lang)!) + : LANGUAGE_OPTIONS.filter(l => l.value === 'react'); + const selectedLang = LANGUAGE_OPTIONS.find(l => l.value === language)!; + const showPreview = interviewType === 'react'; + return ( -
+
+ {/* Language dropdown — only shown for leetcode interviews */} + {interviewType === 'leetcode' &&
+
+ + {langDropdownOpen && ( +
+ {availableLanguages.map(opt => ( + + ))} +
+ )} +
+
} {isDragging &&
}
- {hasError && ( -
- +
+ )} + - Reload Preview - + + + + ) : ( +
+
+ Output + +
+
+ {runError && ( +

{runError}

+ )} + {runOutput && ( + <> + {runOutput.stdout && ( +
{runOutput.stdout}
+ )} + {runOutput.stderr && ( +
{runOutput.stderr}
+ )} +

+ exited with code {runOutput.code} +

+ + )} + {!runOutput && !runError && !isRunning && ( +

press Run to execute your code

+ )} +
)} - - -
); diff --git a/frontend/src/components/room/RoomPage.tsx b/frontend/src/components/room/RoomPage.tsx index c31e563..751bad2 100644 --- a/frontend/src/components/room/RoomPage.tsx +++ b/frontend/src/components/room/RoomPage.tsx @@ -1,16 +1,22 @@ -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useState, useEffect, useContext, useRef } from 'react'; import { joinRoom, getRoomName } from '../../api/api'; import EnterName from './EnterName'; -import CodeEditor from './CodeEditor'; +import CodeEditor, { type InterviewType } from './CodeEditor'; import Popup from '../popup/Popup'; import { DarkModeContext, UserContext } from '../../App'; -import { DEFAULT_CODE } from '../../util/templateContent'; +import { DEFAULT_CODE } from '../../util/reactTemplateContent'; const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:7778'; -function RoomPage() { +interface RoomPageProps { + interviewType?: InterviewType; +} + +function RoomPage({ interviewType: propInterviewType }: RoomPageProps) { const { roomId } = useParams<{ roomId: string }>(); const navigate = useNavigate(); + const location = useLocation(); + const interviewType: InterviewType = (location.state as { interviewType?: InterviewType })?.interviewType ?? propInterviewType ?? 'react'; const { isDark } = useContext(DarkModeContext); const { userId } = useContext(UserContext); const [userName, setUserName] = useState(''); @@ -320,6 +326,8 @@ function RoomPage() { code={code} setCode={setCode} ws={ws} + roomId={roomId!} + interviewType={interviewType} users={users} /> {/* Toast notifications */} diff --git a/frontend/src/components/templates/TemplatesPage.tsx b/frontend/src/components/templates/TemplatesPage.tsx index ae4bb5d..a0a622c 100644 --- a/frontend/src/components/templates/TemplatesPage.tsx +++ b/frontend/src/components/templates/TemplatesPage.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { DarkModeContext, UserContext } from '../../App'; import { createRoom } from '../../api/api'; -import { TEMPLATES, type Template } from '../../util/templateContent'; +import { TEMPLATES, type Template } from '../../util/reactTemplateContent'; function TemplatesPage() { const { isDark } = useContext(DarkModeContext); @@ -18,7 +18,7 @@ function TemplatesPage() { const handleSelectTemplate = async (template: Template) => { const finalRoomName = roomName || template.name; - const response = await createRoom(userId, userName, finalRoomName, template.code); + const response = await createRoom(userId, userName, finalRoomName, 'react', template.code); if (response.ok) { const roomId = response.data.roomId; const expiry = new Date().getTime() + (24 * 60 * 60 * 1000); diff --git a/frontend/src/util/templateContent.example.ts b/frontend/src/util/reactTemplateContent.example.ts similarity index 100% rename from frontend/src/util/templateContent.example.ts rename to frontend/src/util/reactTemplateContent.example.ts diff --git a/go.mod b/go.mod index a342fec..8aacf00 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,9 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -36,8 +37,10 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect diff --git a/go.sum b/go.sum index 3949b0c..288351a 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,16 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -70,6 +74,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -85,6 +91,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= diff --git a/handlers/execute.go b/handlers/execute.go new file mode 100644 index 0000000..6dc5441 --- /dev/null +++ b/handlers/execute.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "goderpad/config" + "goderpad/execution" +) + +type executeRequest struct { + Language string `json:"language"` + Code string `json:"code"` +} + +func ExecuteHandler(c *gin.Context) { + if !config.GetEnableCodeExecution() { + c.JSON(http.StatusOK, execution.Result{ + Stdout: "", + Stderr: "code execution is disabled! to enable, set enable_code_execution: true in config/config.yml and restart the server.", + Code: -1, + }) + return + } + + var req executeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "invalid request"}) + return + } + + result, err := execution.RunCode(req.Language, req.Code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} diff --git a/handlers/http.go b/handlers/http.go index 2cff1db..1c00a23 100644 --- a/handlers/http.go +++ b/handlers/http.go @@ -19,7 +19,7 @@ func CreateRoomHandler(c *gin.Context) { return } - roomID, err := services.CreateRoom(req.UserID, req.Name, req.RoomName, req.InitialCode) + roomID, err := services.CreateRoom(req.UserID, req.Name, req.RoomName, req.Language, req.InitialCode) if err != nil { if errors.Is(err, models.ErrRoomExists) || errors.Is(err, models.ErrRoomNil) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -55,6 +55,7 @@ func JoinRoomHandler(c *gin.Context) { "data": map[string]any{ "roomName": response["roomName"], "document": response["document"], + "language": response["language"], "users": response["users"], }, }) @@ -85,6 +86,32 @@ func GetRoomNameHandler(c *gin.Context) { }) } +func SwitchLanguageHandler(c *gin.Context) { + var req models.SwitchLanguageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + document, err := services.SwitchLanguage(req.RoomID, req.Language) + if err != nil { + if errors.Is(err, models.ErrRoomNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "data": map[string]any{ + "document": document, + "language": req.Language, + }, + }) +} + func ValidateKeyHandler(c *gin.Context) { apiKey := c.GetHeader("x-api-key") if apiKey == "" { diff --git a/handlers/ws.go b/handlers/ws.go index 2bde599..d56bdf3 100644 --- a/handlers/ws.go +++ b/handlers/ws.go @@ -1,14 +1,19 @@ package handlers import ( + "context" + "fmt" "log" "net/http" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "goderpad/config" "goderpad/metrics" "goderpad/models" + "goderpad/services" ) var upgrader = websocket.Upgrader{ @@ -114,6 +119,44 @@ func readBroadcastsFromUser(user *models.User, room *models.Room) { continue } } + if msg.Type == string(models.ExecuteRequestMessageType) { + if !config.GetEnableCodeExecution() { + user.Send <- models.BroadcastMessage{ + UserID: user.UserID, + Type: string(models.ExecuteResultMessageType), + Payload: map[string]any{ + "userId": user.UserID, + "stdout": "", + "stderr": "code execution is disabled! to enable, set enable_code_execution: true in config/config.yml and restart the server.", + "code": -1, + }, + } + continue + } + language, _ := msg.Payload["language"].(string) + code, _ := msg.Payload["code"].(string) + jobID := fmt.Sprintf("%s:%s:%d", room.RoomID, user.UserID, time.Now().UnixNano()) + job := models.ExecuteJob{ + JobID: jobID, + RoomID: room.RoomID, + UserID: user.UserID, + Language: language, + Code: code, + } + if err := services.PublishJob(context.Background(), job); err != nil { + user.Send <- models.BroadcastMessage{ + UserID: user.UserID, + Type: string(models.ExecuteResultMessageType), + Payload: map[string]any{ + "userId": user.UserID, + "stdout": "", + "stderr": "failed to queue execution: " + err.Error(), + "code": -1, + }, + } + } + continue + } room.Broadcast <- msg } } diff --git a/models/http.go b/models/http.go index 7595875..4242c24 100644 --- a/models/http.go +++ b/models/http.go @@ -4,6 +4,7 @@ type CreateRoomRequest struct { UserID string `json:"userId"` Name string `json:"name"` RoomName string `json:"roomName"` + Language string `json:"language"` InitialCode string `json:"initialCode"` } @@ -12,3 +13,8 @@ type JoinRoomRequest struct { Name string `json:"name"` RoomID string `json:"roomId"` } + +type SwitchLanguageRequest struct { + RoomID string `json:"roomId"` + Language string `json:"language"` +} diff --git a/models/job.go b/models/job.go new file mode 100644 index 0000000..96f85d8 --- /dev/null +++ b/models/job.go @@ -0,0 +1,18 @@ +package models + +type ExecuteJob struct { + JobID string `json:"jobId"` + RoomID string `json:"roomId"` + UserID string `json:"userId"` + Language string `json:"language"` + Code string `json:"code"` +} + +type ExecuteResult struct { + JobID string `json:"jobId"` + RoomID string `json:"roomId"` + UserID string `json:"userId"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Code int `json:"code"` +} diff --git a/models/room.go b/models/room.go index 1ee5d46..6caebb6 100644 --- a/models/room.go +++ b/models/room.go @@ -14,7 +14,8 @@ type Room struct { RoomID string `json:"roomId"` RoomName string `json:"roomName"` CreatedAt time.Time `json:"-"` - Document string `json:"document"` + Language string `json:"language"` + Documents map[string]string `json:"documents"` Users map[string]*User `json:"users"` Broadcast chan BroadcastMessage `json:"-"` done chan struct{} `json:"-"` @@ -26,16 +27,19 @@ type Room struct { saveDebounce *time.Timer `json:"-"` } -func NewRoom(roomID, roomName, initialCode string) *Room { - document := utils.DEFAULT_CODE +func NewRoom(roomID, roomName, language, initialCode string) *Room { + document := utils.DefaultCodeForLanguage(language) if initialCode != "" { document = initialCode } + documents := make(map[string]string) + documents[language] = document room := &Room{ RoomID: roomID, RoomName: roomName, CreatedAt: time.Now(), - Document: document, + Language: language, + Documents: documents, Users: make(map[string]*User), done: make(chan struct{}), Broadcast: make(chan BroadcastMessage), @@ -49,6 +53,23 @@ func (r *Room) Close() { close(r.Broadcast) } +func (r *Room) GetDocument() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.Documents[r.Language] +} + +func (r *Room) SwitchLanguage(language string) string { + r.mu.Lock() + defer r.mu.Unlock() + r.Language = language + if doc, exists := r.Documents[language]; exists { + return doc + } + r.Documents[language] = utils.DefaultCodeForLanguage(language) + return r.Documents[language] +} + func (r *Room) AddUser(user *User) { r.mu.Lock() defer r.mu.Unlock() @@ -92,7 +113,7 @@ func (r *Room) BroadcastToUsers() { r.mu.Lock() if msg.Type == "code_update" { if code, ok := msg.Payload["code"].(string); ok { - r.Document = code + r.Documents[r.Language] = code r.dirty = true r.scheduleSave() } @@ -133,7 +154,7 @@ func (r *Room) saveToFile() { } filePath := filepath.Join(dirPath, r.RoomName) - if err := os.WriteFile(filePath, []byte(r.Document), 0644); err != nil { + if err := os.WriteFile(filePath, []byte(r.Documents[r.Language]), 0644); err != nil { metrics.DocumentSavesErrorsTotal.Inc() return } diff --git a/models/ws.go b/models/ws.go index 8e25300..ad41d3e 100644 --- a/models/ws.go +++ b/models/ws.go @@ -9,6 +9,8 @@ const ( CodeUpdateMessageType MessageType = "code_update" SelectionUpdateMessageType MessageType = "selection_update" VisibilityChangeMessageType MessageType = "visibility_change" + ExecuteRequestMessageType MessageType = "execute_request" + ExecuteResultMessageType MessageType = "execute_result" ) type BroadcastMessage struct { diff --git a/redisclient/client.go b/redisclient/client.go new file mode 100644 index 0000000..e802fc8 --- /dev/null +++ b/redisclient/client.go @@ -0,0 +1,38 @@ +package redisclient + +import ( + "context" + "fmt" + "log" + + "github.com/redis/go-redis/v9" + + "goderpad/config" +) + +var client *redis.Client + +func Init() error { + client = redis.NewClient(&redis.Options{ + Addr: config.GetRedisAddr(), + Password: config.GetRedisPassword(), + DB: config.GetRedisDB(), + }) + + if err := client.Ping(context.Background()).Err(); err != nil { + return fmt.Errorf("failed to connect to redis: %w", err) + } + + log.Printf("connected to redis at %s", config.GetRedisAddr()) + return nil +} + +func GetClient() *redis.Client { + return client +} + +func Close() { + if client != nil { + client.Close() + } +} diff --git a/services/jobqueue.go b/services/jobqueue.go new file mode 100644 index 0000000..a6cf882 --- /dev/null +++ b/services/jobqueue.go @@ -0,0 +1,128 @@ +package services + +import ( + "context" + "encoding/json" + "log" + "os" + "time" + + "github.com/redis/go-redis/v9" + + "goderpad/models" + "goderpad/redisclient" +) + +const ( + JobsStream = "goderpad:jobs" + ResultsStream = "goderpad:results" + ServerGroup = "server" +) + +func serverConsumerName() string { + hostname, _ := os.Hostname() + return "server-" + hostname +} + +// PublishJob adds an execution job to the Redis jobs stream. +func PublishJob(ctx context.Context, job models.ExecuteJob) error { + data, err := json.Marshal(job) + if err != nil { + return err + } + + return redisclient.GetClient().XAdd(ctx, &redis.XAddArgs{ + Stream: JobsStream, + MaxLen: 1000, + Approx: true, + Values: map[string]interface{}{ + "data": string(data), + }, + }).Err() +} + +// StartResultListener consumes execution results from Redis and routes them +// back to the appropriate room's WebSocket connections. +func StartResultListener(ctx context.Context) { + rdb := redisclient.GetClient() + consumer := serverConsumerName() + + // Create consumer group, ignore error if it already exists + err := rdb.XGroupCreateMkStream(ctx, ResultsStream, ServerGroup, "0").Err() + if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" { + log.Printf("warning: could not create results consumer group: %v", err) + } + + log.Printf("result listener started (consumer=%s)", consumer) + + for { + select { + case <-ctx.Done(): + log.Println("result listener shutting down") + return + default: + } + + streams, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{ + Group: ServerGroup, + Consumer: consumer, + Streams: []string{ResultsStream, ">"}, + Count: 10, + Block: 5 * time.Second, + }).Result() + + if err != nil { + if err == redis.Nil || ctx.Err() != nil { + continue + } + log.Printf("error reading results stream: %v", err) + time.Sleep(1 * time.Second) + continue + } + + for _, stream := range streams { + for _, msg := range stream.Messages { + handleResultMessage(ctx, rdb, msg) + } + } + } +} + +func handleResultMessage(ctx context.Context, rdb *redis.Client, msg redis.XMessage) { + data, ok := msg.Values["data"].(string) + if !ok { + log.Printf("invalid result message: %s", msg.ID) + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) + return + } + + var result models.ExecuteResult + if err := json.Unmarshal([]byte(data), &result); err != nil { + log.Printf("failed to unmarshal result: %v", err) + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) + return + } + + hub := models.GetHub() + room, exists := hub.GetRoom(result.RoomID) + if !exists { + log.Printf("result for unknown room %s, discarding", result.RoomID) + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) + return + } + + // Send execute_result to ALL users in the room (including the one who triggered it). + // Using empty UserID so BroadcastToUsers won't skip anyone. + room.Broadcast <- models.BroadcastMessage{ + UserID: "", + Type: string(models.ExecuteResultMessageType), + Payload: map[string]any{ + "userId": result.UserID, + "stdout": result.Stdout, + "stderr": result.Stderr, + "code": result.Code, + }, + } + + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) +} diff --git a/services/room.go b/services/room.go index d175652..b0dfb53 100644 --- a/services/room.go +++ b/services/room.go @@ -14,9 +14,9 @@ import ( // CreateRoom creates a new room with the given details. // It does NOT add a user to the room, that is handled in the JoinRoom function. -func CreateRoom(userID, name, roomName, initialCode string) (string, error) { +func CreateRoom(userID, name, roomName, language, initialCode string) (string, error) { roomID := utils.GenerateRoomCode() - room := models.NewRoom(roomID, roomName, initialCode) + room := models.NewRoom(roomID, roomName, language, initialCode) hub := models.GetHub() if err := hub.AddRoom(room); err != nil { @@ -46,12 +46,23 @@ func JoinRoom(userID, name, roomID string) (map[string]any, error) { response := map[string]any{ "roomName": room.RoomName, - "document": room.Document, + "document": room.GetDocument(), + "language": room.Language, "users": room.GetCurrentUsers(), } return response, nil } +func SwitchLanguage(roomID, language string) (string, error) { + hub := models.GetHub() + room, exists := hub.GetRoom(roomID) + if !exists { + return "", models.ErrRoomNotFound + } + document := room.SwitchLanguage(language) + return document, nil +} + func GetRoomName(roomID string) (string, error) { hub := models.GetHub() room, exists := hub.GetRoom(roomID) diff --git a/utils/defaultCode.go b/utils/defaultCode.go index 3c2974c..1b05ec6 100644 --- a/utils/defaultCode.go +++ b/utils/defaultCode.go @@ -1,3 +1,29 @@ package utils -const DEFAULT_CODE = "import React from 'react';\n\nfunction App() {\n return (\n
\n

Hello, World!

\n
\n );\n}\nexport default App;" +const DEFAULT_REACT_CODE = "import React from 'react';\n\nfunction App() {\n return (\n
\n

Hello, World!

\n
\n );\n}\nexport default App;" + +const DEFAULT_PYTHON_CODE = "def main():\n print(\"Hello, World!\")\n main()" + +const DEFAULT_JAVA_CODE = "public class Main {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}" + +const DEFAULT_CPP_CODE = "#include \n\nint main() {\n std::cout << \"Hello, World!\" << std::endl;\n return 0;\n}" + +const DEFAULT_JAVASCRIPT_CODE = "function main() {\n console.log(\"Hello, World!\");\n}\n main()" + +// DEFAULT_CODE kept for backwards compatibility +const DEFAULT_CODE = DEFAULT_REACT_CODE + +func DefaultCodeForLanguage(language string) string { + switch language { + case "python": + return DEFAULT_PYTHON_CODE + case "java": + return DEFAULT_JAVA_CODE + case "cpp": + return DEFAULT_CPP_CODE + case "javascript": + return DEFAULT_JAVASCRIPT_CODE + default: // "react" and anything unrecognised + return DEFAULT_REACT_CODE + } +}