diff --git a/backend/server/.env.example b/backend/server/.env.example index 594efc5..e9e502a 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -1,8 +1,13 @@ # Set WORKERS_KEY to be the same as KEY in /backend/storage/wrangler.toml. # Set DATABASE_WORKER_URL and STORAGE_WORKER_URL after deploying the workers. +# DOKKU_HOST and DOKKU_USERNAME are used to authenticate via SSH with the Dokku server +# DOKKU_KEY is the path to an SSH (.pem) key on the local machine PORT=4000 WORKERS_KEY= DATABASE_WORKER_URL= STORAGE_WORKER_URL= -E2B_API_KEY= \ No newline at end of file +E2B_API_KEY= +DOKKU_HOST= +DOKKU_USERNAME= +DOKKU_KEY= \ No newline at end of file diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json index 8c7b2a0..288efe9 100644 --- a/backend/server/package-lock.json +++ b/backend/server/package-lock.json @@ -15,13 +15,16 @@ "e2b": "^0.16.1", "express": "^4.19.2", "rate-limiter-flexible": "^5.0.3", + "simple-git": "^3.25.0", "socket.io": "^4.7.5", + "ssh2": "^1.15.0", "zod": "^3.22.4" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.12.7", + "@types/ssh2": "^1.15.0", "nodemon": "^3.1.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" @@ -75,6 +78,40 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/file-exists/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@kwsites/file-exists/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.1.tgz", @@ -213,6 +250,24 @@ "@types/send": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==", + "dev": true, + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.41.tgz", + "integrity": "sha512-LX84pRJ+evD2e2nrgYCHObGWkiQJ1mL+meAgbvnwk/US6vmMY7S2ygBTGV2Jw91s9vUsLSXeDEkUHZIJGLrhsg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -298,6 +353,14 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -312,6 +375,14 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -382,6 +453,15 @@ "node": ">=6.14.2" } }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -593,6 +673,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1247,6 +1341,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", + "optional": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1630,6 +1730,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-git": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.25.0.tgz", + "integrity": "sha512-KIY5sBnzc4yEcJXW7Tdv4viEz8KyG+nU0hay+DWZasvdFOYKeUZ6Xc25LUHHjw0tinPT7O1eY6pzX7pRT1K8rw==", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-git/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/simple-git/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1748,6 +1883,23 @@ "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==" }, + "node_modules/ssh2": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.9", + "nan": "^2.18.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1880,6 +2032,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/backend/server/package.json b/backend/server/package.json index 3b89d67..19e5ee5 100644 --- a/backend/server/package.json +++ b/backend/server/package.json @@ -17,13 +17,16 @@ "e2b": "^0.16.1", "express": "^4.19.2", "rate-limiter-flexible": "^5.0.3", + "simple-git": "^3.25.0", "socket.io": "^4.7.5", + "ssh2": "^1.15.0", "zod": "^3.22.4" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.12.7", + "@types/ssh2": "^1.15.0", "nodemon": "^3.1.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" diff --git a/backend/server/src/DokkuClient.ts b/backend/server/src/DokkuClient.ts new file mode 100644 index 0000000..fd0adcd --- /dev/null +++ b/backend/server/src/DokkuClient.ts @@ -0,0 +1,37 @@ +import { SSHSocketClient, SSHConfig } from "./SSHSocketClient" + +export interface DokkuResponse { + ok: boolean; + output: string; +} + +export class DokkuClient extends SSHSocketClient { + + constructor(config: SSHConfig) { + super( + config, + "/var/run/dokku-daemon/dokku-daemon.sock" + ) + } + + async sendCommand(command: string): Promise { + try { + const response = await this.sendData(command); + + if (typeof response !== "string") { + throw new Error("Received data is not a string"); + } + + return JSON.parse(response); + } catch (error: any) { + throw new Error(`Failed to send command: ${error.message}`); + } + } + + async listApps(): Promise { + const response = await this.sendCommand("apps:list"); + return response.output.split("\n").slice(1); // Split by newline and ignore the first line (header) + } +} + +export { SSHConfig }; \ No newline at end of file diff --git a/backend/server/src/SSHSocketClient.ts b/backend/server/src/SSHSocketClient.ts new file mode 100644 index 0000000..e0dc043 --- /dev/null +++ b/backend/server/src/SSHSocketClient.ts @@ -0,0 +1,90 @@ +import { Client } from "ssh2"; + +export interface SSHConfig { + host: string; + port?: number; + username: string; + privateKey: Buffer; +} + +export class SSHSocketClient { + private conn: Client; + private config: SSHConfig; + private socketPath: string; + private isConnected: boolean = false; + + constructor(config: SSHConfig, socketPath: string) { + this.conn = new Client(); + this.config = { ...config, port: 22}; + this.socketPath = socketPath; + + this.setupTerminationHandlers(); + } + + private setupTerminationHandlers() { + process.on("SIGINT", this.closeConnection.bind(this)); + process.on("SIGTERM", this.closeConnection.bind(this)); + } + + private closeConnection() { + console.log("Closing SSH connection..."); + this.conn.end(); + this.isConnected = false; + process.exit(0); + } + + connect(): Promise { + return new Promise((resolve, reject) => { + this.conn + .on("ready", () => { + console.log("SSH connection established"); + this.isConnected = true; + resolve(); + }) + .on("error", (err) => { + console.error("SSH connection error:", err); + this.isConnected = false; + reject(err); + }) + .on("close", () => { + console.log("SSH connection closed"); + this.isConnected = false; + }) + .connect(this.config); + }); + } + + sendData(data: string): Promise { + return new Promise((resolve, reject) => { + if (!this.isConnected) { + reject(new Error("SSH connection is not established")); + return; + } + + this.conn.exec( + `echo "${data}" | nc -U ${this.socketPath}`, + (err, stream) => { + if (err) { + reject(err); + return; + } + + stream + .on("close", (code: number, signal: string) => { + reject( + new Error( + `Stream closed with code ${code} and signal ${signal}` + ) + ); + }) + .on("data", (data: Buffer) => { + resolve(data.toString()); + }) + .stderr.on("data", (data: Buffer) => { + reject(new Error(data.toString())); + }); + } + ); + }); + } + } \ No newline at end of file diff --git a/backend/server/src/SecureGitClient.ts b/backend/server/src/SecureGitClient.ts new file mode 100644 index 0000000..5c115b8 --- /dev/null +++ b/backend/server/src/SecureGitClient.ts @@ -0,0 +1,80 @@ +import simpleGit, { SimpleGit } from "simple-git"; +import path from "path"; +import fs from "fs"; +import os from "os"; + +export type FileData = { + id: string; + data: string; +}; + +export class SecureGitClient { + private gitUrl: string; + private sshKeyPath: string; + + constructor(gitUrl: string, sshKeyPath: string) { + this.gitUrl = gitUrl; + this.sshKeyPath = sshKeyPath; + } + + async pushFiles(fileData: FileData[], repository: string): Promise { + let tempDir: string | undefined; + + try { + // Create a temporary directory + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-push-')); + console.log(`Temporary directory created: ${tempDir}`); + + // Write files to the temporary directory + for (const { id, data } of fileData) { + const filePath = path.join(tempDir, id); + const dirPath = path.dirname(filePath); + + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + console.log("Writing ", filePath, data); + fs.writeFileSync(filePath, data); + } + + // Initialize the simple-git instance with the temporary directory and custom SSH command + const git: SimpleGit = simpleGit(tempDir, { + config: [ + 'core.sshCommand=ssh -i ' + this.sshKeyPath + ' -o IdentitiesOnly=yes' + ] + }); + + // Initialize a new Git repository + await git.init(); + + // Add remote repository + await git.addRemote("origin", `${this.gitUrl}:${repository}`); + + // Add files to the repository + for (const {id, data} of fileData) { + await git.add(id); + } + + // Commit the changes + await git.commit("Add files."); + + // Push the changes to the remote repository + await git.push("origin", "master"); + + console.log("Files successfully pushed to the repository"); + + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + console.log(`Temporary directory removed: ${tempDir}`); + } + } catch (error) { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + console.log(`Temporary directory removed: ${tempDir}`); + } + console.error("Error pushing files to the repository:", error); + throw error; + } + } +} \ No newline at end of file diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index 733d366..a248909 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -1,10 +1,12 @@ -import os from "os"; import path from "path"; import cors from "cors"; import express, { Express } from "express"; import dotenv from "dotenv"; import { createServer } from "http"; import { Server } from "socket.io"; +import { DokkuClient } from "./DokkuClient"; +import { SecureGitClient, FileData } from "./SecureGitClient"; +import fs from "fs"; import { z } from "zod"; import { User } from "./types"; @@ -112,6 +114,23 @@ io.use(async (socket, next) => { const lockManager = new LockManager(); +if (!process.env.DOKKU_HOST) throw new Error('Environment variable DOKKU_HOST is not defined'); +if (!process.env.DOKKU_USERNAME) throw new Error('Environment variable DOKKU_USERNAME is not defined'); +if (!process.env.DOKKU_KEY) throw new Error('Environment variable DOKKU_KEY is not defined'); + +const client = new DokkuClient({ + host: process.env.DOKKU_HOST, + username: process.env.DOKKU_USERNAME, + privateKey: fs.readFileSync(process.env.DOKKU_KEY), +}); + +client.connect(); + +const git = new SecureGitClient( + "dokku@gitwit.app", + process.env.DOKKU_KEY +) + io.on("connection", async (socket) => { try { if (inactivityTimeout) clearTimeout(inactivityTimeout); @@ -137,10 +156,6 @@ io.on("connection", async (socket) => { if (!containers[data.sandboxId]) { containers[data.sandboxId] = await Sandbox.create(); console.log("Created container ", data.sandboxId); - io.emit( - "previewURL", - "https://" + containers[data.sandboxId].getHostname(5173) - ); } } catch (e: any) { console.error(`Error creating container ${data.sandboxId}:`, e); @@ -254,6 +269,57 @@ io.on("connection", async (socket) => { } ); + interface CallbackResponse { + success: boolean; + apps?: string[]; + message?: string; + } + + socket.on( + "list", + async (callback: (response: CallbackResponse) => void) => { + console.log("Retrieving apps list..."); + try { + callback({ + success: true, + apps: await client.listApps() + }); + } catch (error) { + callback({ + success: false, + message: "Failed to retrieve apps list", + }); + } + } + ); + + socket.on( + "deploy", + async (callback: (response: CallbackResponse) => void) => { + try { + // Push the project files to the Dokku server + console.log("Deploying project ${data.sandboxId}..."); + // Remove the /project/[id]/ component of each file path: + const fixedFilePaths = sandboxFiles.fileData.map((file) => { + return { + ...file, + id: file.id.split("/").slice(2).join("/"), + }; + }); + // Push all files to Dokku. + await git.pushFiles(fixedFilePaths, data.sandboxId); + callback({ + success: true, + }); + } catch (error) { + callback({ + success: false, + message: "Failed to deploy project: " + error, + }); + } + } + ); + socket.on("createFile", async (name: string, callback) => { try { const size: number = await getProjectSize(data.sandboxId); @@ -422,8 +488,26 @@ io.on("connection", async (socket) => { await lockManager.acquireLock(data.sandboxId, async () => { try { terminals[id] = await containers[data.sandboxId].terminal.start({ - onData: (data: string) => { - io.emit("terminalResponse", { id, data }); + onData: (responseData: string) => { + io.emit("terminalResponse", { id, data: responseData }); + + function extractPortNumber(inputString: string) { + // Remove ANSI escape codes + const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, ''); + // Regular expression to match port number + const regex = /http:\/\/localhost:(\d+)/; + // If a match is found, return the port number + const match = cleanedString.match(regex); + return match ? match[1] : null; + } + const port = parseInt(extractPortNumber(responseData) ?? ""); + if (port) { + io.emit( + "previewURL", + "https://" + containers[data.sandboxId].getHostname(port) + ); + } + }, size: { cols: 80, rows: 20 }, onExit: () => console.log("Terminal exited", id), diff --git a/backend/storage/src/startercode.ts b/backend/storage/src/startercode.ts index 3979f9b..ae768e1 100644 --- a/backend/storage/src/startercode.ts +++ b/backend/storage/src/startercode.ts @@ -21,49 +21,49 @@ const startercode = { { name: "package.json", body: `{ - "name": "react", + "name": "react-app", + "version": "0.1.0", "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" - }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-scripts": "5.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] }, "devDependencies": { "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", - "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.6", - "vite": "^5.2.0" + "eslint-plugin-react-hooks": "^4.6.0" } }`, }, { - name: "vite.config.js", - body: `import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - server: { - port: 5173, - host: "0.0.0.0", - } -}) -`, - }, - { - name: "index.html", + name: "public/index.html", body: ` @@ -133,7 +133,7 @@ export default App `, }, { - name: "src/main.jsx", + name: "src/index.js", body: `import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index bdc4959..79f8b5d 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -6,7 +6,8 @@ import { ThemeProvider } from "@/components/layout/themeProvider" import { ClerkProvider } from "@clerk/nextjs" import { Toaster } from "@/components/ui/sonner" import { Analytics } from "@vercel/analytics/react" -import { PHProvider } from "./providers" +import { TerminalProvider } from '@/context/TerminalContext'; +import { PreviewProvider } from "@/context/PreviewContext" export const metadata: Metadata = { title: "Sandbox", @@ -21,21 +22,23 @@ export default function RootLayout({ return ( - - - - {children} - - - - - + + + + + {children} + + + + + + ) -} +} \ No newline at end of file diff --git a/frontend/components/editor/index.tsx b/frontend/components/editor/index.tsx index 8ae8373..2e719b9 100644 --- a/frontend/components/editor/index.tsx +++ b/frontend/components/editor/index.tsx @@ -31,6 +31,8 @@ import Loading from "./loading" import PreviewWindow from "./preview" import Terminals from "./terminals" import { ImperativePanelHandle } from "react-resizable-panels" +import { PreviewProvider, usePreview } from '@/context/PreviewContext'; +import { useTerminal } from '@/context/TerminalContext'; export default function CodeEditor({ userData, @@ -48,8 +50,17 @@ export default function CodeEditor({ { timeout: 2000, } - );} + ); + } + //Terminalcontext functionsand effects + const { setUserAndSandboxId } = useTerminal(); + + useEffect(() => { + setUserAndSandboxId(userData.id, sandboxData.id); + }, [userData.id, sandboxData.id, setUserAndSandboxId]); + + //Preview Button state const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true) const [disableAccess, setDisableAccess] = useState({ isDisabled: false, @@ -315,7 +326,7 @@ export default function CodeEditor({ console.log(`Saving file...${activeFileId}`); console.log(`Saving file...${value}`); socketRef.current?.emit("saveFile", activeFileId, value); - }, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY)||1000), + }, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000), [socketRef] ); @@ -341,7 +352,7 @@ export default function CodeEditor({ if (!editorRef || !tab || !model) return let providerData: ProviderData; - + // When a file is opened for the first time, create a new provider and store in providersMap. if (!providersMap.current.has(tab.id)) { const yDoc = new Y.Doc(); @@ -383,7 +394,6 @@ export default function CodeEditor({ ); providerData.binding = binding; - setProvider(providerData.provider); return () => { @@ -397,25 +407,24 @@ export default function CodeEditor({ }; }, [room, activeFileContent]); - // Added this effect to clean up when the component unmounts - useEffect(() => { - return () => { - // Clean up all providers when the component unmounts - providersMap.current.forEach((data) => { - if (data.binding) { - data.binding.destroy(); - } - data.provider.disconnect(); - data.yDoc.destroy(); - }); - providersMap.current.clear(); - }; - }, []); + // Added this effect to clean up when the component unmounts + useEffect(() => { + return () => { + // Clean up all providers when the component unmounts + providersMap.current.forEach((data) => { + if (data.binding) { + data.binding.destroy(); + } + data.provider.disconnect(); + data.yDoc.destroy(); + }); + providersMap.current.clear(); + }; + }, []); // Connection/disconnection effect useEffect(() => { socketRef.current?.connect() - return () => { socketRef.current?.disconnect() } @@ -423,7 +432,7 @@ export default function CodeEditor({ // Socket event listener effect useEffect(() => { - const onConnect = () => {} + const onConnect = () => { } const onDisconnect = () => { setTerminals([]) @@ -528,8 +537,8 @@ export default function CodeEditor({ ? numTabs === 1 ? null : index < numTabs - 1 - ? tabs[index + 1].id - : tabs[index - 1].id + ? tabs[index + 1].id + : tabs[index - 1].id : activeFileId setTabs((prev) => prev.filter((t) => t.id !== id)) @@ -622,7 +631,7 @@ export default function CodeEditor({ {}} + setOpen={() => { }} /> @@ -631,216 +640,211 @@ export default function CodeEditor({ return ( <> {/* Copilot DOM elements */} -
-
- {generate.show && ai ? ( - t.id === activeFileId)?.name ?? "", - code: editorRef?.getValue() ?? "", - line: generate.line, - }} - editor={{ - language: editorLanguage, - }} - onExpand={() => { - editorRef?.changeViewZones(function (changeAccessor) { - changeAccessor.removeZone(generate.id) + +
+
+ {generate.show && ai ? ( + t.id === activeFileId)?.name ?? "", + code: editorRef?.getValue() ?? "", + line: generate.line, + }} + editor={{ + language: editorLanguage, + }} + onExpand={() => { + editorRef?.changeViewZones(function (changeAccessor) { + changeAccessor.removeZone(generate.id) - if (!generateRef.current) return - const id = changeAccessor.addZone({ - afterLineNumber: cursorLine, - heightInLines: 12, - domNode: generateRef.current, + if (!generateRef.current) return + const id = changeAccessor.addZone({ + afterLineNumber: cursorLine, + heightInLines: 12, + domNode: generateRef.current, + }) + setGenerate((prev) => { + return { ...prev, id } + }) }) + }} + onAccept={(code: string) => { + const line = generate.line setGenerate((prev) => { - return { ...prev, id } + return { + ...prev, + show: !prev.show, + } }) - }) - }} - onAccept={(code: string) => { - const line = generate.line - setGenerate((prev) => { - return { - ...prev, - show: !prev.show, - } - }) - const file = editorRef?.getValue() + const file = editorRef?.getValue() - const lines = file?.split("\n") || [] - lines.splice(line - 1, 0, code) - const updatedFile = lines.join("\n") - editorRef?.setValue(updatedFile) - }} - onClose={() => { - setGenerate((prev) => { - return { - ...prev, - show: !prev.show, - } - }) - }} - /> - ) : null} -
+ const lines = file?.split("\n") || [] + lines.splice(line - 1, 0, code) + const updatedFile = lines.join("\n") + editorRef?.setValue(updatedFile) + }} + onClose={() => { + setGenerate((prev) => { + return { + ...prev, + show: !prev.show, + } + }) + }} + /> + ) : null} +
- {/* Main editor components */} - addNew(name, type, setFiles, sandboxData)} - deletingFolderId={deletingFolderId} - // AI Copilot Toggle - ai={ai} - setAi={setAi} - /> + {/* Main editor components */} + addNew(name, type, setFiles, sandboxData)} + deletingFolderId={deletingFolderId} + // AI Copilot Toggle + ai={ai} + setAi={setAi} + /> - {/* Shadcn resizeable panels: https://ui.shadcn.com/docs/components/resizable */} - - -
- {/* File tabs */} - {tabs.map((tab) => ( - { - selectFile(tab) - }} - onClose={() => closeTab(tab.id)} - > - {tab.name} - - ))} -
- {/* Monaco editor */} -
+ - {!activeFileId ? ( - <> -
- - No file selected. -
- - ) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643 - clerk.loaded ? ( - <> - {provider && userInfo ? ( - - ) : null} - { - if (value === activeFileContent) { - setTabs((prev) => - prev.map((tab) => - tab.id === activeFileId - ? { ...tab, saved: true } - : tab - ) - ) - } else { - setTabs((prev) => - prev.map((tab) => - tab.id === activeFileId - ? { ...tab, saved: false } - : tab - ) - ) - } +
+ {/* File tabs */} + {tabs.map((tab) => ( + { + selectFile(tab) }} - options={{ - tabSize: 2, - minimap: { - enabled: false, - }, - padding: { - bottom: 4, - top: 4, - }, - scrollBeyondLastLine: false, - fixedOverflowWidgets: true, - fontFamily: "var(--font-geist-mono)", - }} - theme="vs-dark" - value={activeFileContent} - /> - - ) : ( -
- - Waiting for Clerk to load... -
- )} -
-
- - - - setIsPreviewCollapsed(true)} - onExpand={() => setIsPreviewCollapsed(false)} + onClose={() => closeTab(tab.id)} + > + {tab.name} + + ))} +
+ {/* Monaco editor */} +
- { - previewPanelRef.current?.expand() - setIsPreviewCollapsed(false) - }} - src={previewURL} - /> - - - - {isOwner ? ( - - ) : ( -
- - No terminal access. -
- )} -
- - - + {!activeFileId ? ( + <> +
+ + No file selected. +
+ + ) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643 + clerk.loaded ? ( + <> + {provider && userInfo ? ( + + ) : null} + { + if (value === activeFileContent) { + setTabs((prev) => + prev.map((tab) => + tab.id === activeFileId + ? { ...tab, saved: true } + : tab + ) + ) + } else { + setTabs((prev) => + prev.map((tab) => + tab.id === activeFileId + ? { ...tab, saved: false } + : tab + ) + ) + } + }} + options={{ + tabSize: 2, + minimap: { + enabled: false, + }, + padding: { + bottom: 4, + top: 4, + }, + scrollBeyondLastLine: false, + fixedOverflowWidgets: true, + fontFamily: "var(--font-geist-mono)", + }} + theme="vs-dark" + value={activeFileContent} + /> + + ) : ( +
+ + Waiting for Clerk to load... +
+ )} +
+
+ + + + setIsPreviewCollapsed(true)} + onExpand={() => setIsPreviewCollapsed(false)} + > + { + usePreview().previewPanelRef.current?.expand() + setIsPreviewCollapsed(false) + } } collapsed={isPreviewCollapsed} src={previewURL}/> + + + + {isOwner ? ( + + ) : ( +
+ + No terminal access. +
+ )} +
+
+
+
+
) } diff --git a/frontend/components/editor/navbar/deploy.tsx b/frontend/components/editor/navbar/deploy.tsx new file mode 100644 index 0000000..c498286 --- /dev/null +++ b/frontend/components/editor/navbar/deploy.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Play, Pause } from "lucide-react"; + +export default function DeployButtonModal() { + const [isDeploying, setIsDeploying] = useState(false); + + const handleDeploy = () => { + if (isDeploying) { + console.log("Stopping deployment..."); + + } else { + console.log("Starting deployment..."); + } + setIsDeploying(!isDeploying); + }; + + return ( + <> + + + ); +} \ No newline at end of file diff --git a/frontend/components/editor/navbar/index.tsx b/frontend/components/editor/navbar/index.tsx index 413200b..652fa03 100644 --- a/frontend/components/editor/navbar/index.tsx +++ b/frontend/components/editor/navbar/index.tsx @@ -11,6 +11,8 @@ import { useState } from "react"; import EditSandboxModal from "./edit"; import ShareSandboxModal from "./share"; import { Avatars } from "../live/avatars"; +import RunButtonModal from "./run"; +import DeployButtonModal from "./deploy"; export default function Navbar({ userData, @@ -19,15 +21,13 @@ export default function Navbar({ }: { userData: User; sandboxData: Sandbox; - shared: { - id: string; - name: string; - }[]; + shared: { id: string; name: string }[]; }) { const [isEditOpen, setIsEditOpen] = useState(false); const [isShareOpen, setIsShareOpen] = useState(false); + const [isRunning, setIsRunning] = useState(false); - const isOwner = sandboxData.userId === userData.id; + const isOwner = sandboxData.userId === userData.id;; return ( <> @@ -62,18 +62,25 @@ export default function Navbar({ ) : null}
+
{isOwner ? ( + <> + + ) : null}
); -} +} \ No newline at end of file diff --git a/frontend/components/editor/navbar/run.tsx b/frontend/components/editor/navbar/run.tsx new file mode 100644 index 0000000..8e5a3b2 --- /dev/null +++ b/frontend/components/editor/navbar/run.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Play, StopCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useTerminal } from "@/context/TerminalContext"; +import { usePreview } from "@/context/PreviewContext"; +import { toast } from "sonner"; + +export default function RunButtonModal({ + isRunning, + setIsRunning, +}: { + isRunning: boolean; + setIsRunning: (running: boolean) => void; +}) { + const { createNewTerminal, terminals, closeTerminal } = useTerminal(); + const { setIsPreviewCollapsed, previewPanelRef} = usePreview(); + + const handleRun = () => { + if (isRunning) { + console.log('Stopping sandbox...'); + console.log('Closing Preview Window'); + + terminals.forEach(term => { + if (term.terminal) { + closeTerminal(term.id); + console.log('Closing Terminal', term.id); + } + }); + + setIsPreviewCollapsed(true); + previewPanelRef.current?.collapse(); + } else { + console.log('Running sandbox...'); + console.log('Opening Terminal'); + console.log('Opening Preview Window'); + + if (terminals.length < 4) { + createNewTerminal("yarn install && yarn start"); + } else { + toast.error("You reached the maximum # of terminals."); + console.error('Maximum number of terminals reached.'); + } + + setIsPreviewCollapsed(false); + previewPanelRef.current?.expand(); + } + setIsRunning(!isRunning); + }; + + return ( + <> + + + ); +} \ No newline at end of file diff --git a/frontend/components/editor/preview/index.tsx b/frontend/components/editor/preview/index.tsx index a400d14..940f8ba 100644 --- a/frontend/components/editor/preview/index.tsx +++ b/frontend/components/editor/preview/index.tsx @@ -1,13 +1,9 @@ "use client" import { - ChevronLeft, - ChevronRight, - Globe, Link, RotateCw, TerminalSquare, - UnfoldVertical, } from "lucide-react" import { useRef, useState } from "react" import { toast } from "sonner" @@ -27,22 +23,22 @@ export default function PreviewWindow({ return ( <>
Preview
{collapsed ? ( - - + { }}> + ) : ( <> - {/* Todo, make this open inspector */} - {/* {}}> - + {/* Removed the unfoldvertical button since we have the same thing via the run button. + + + */} {children} diff --git a/frontend/components/editor/terminals/index.tsx b/frontend/components/editor/terminals/index.tsx index a777eeb..076f121 100644 --- a/frontend/components/editor/terminals/index.tsx +++ b/frontend/components/editor/terminals/index.tsx @@ -2,35 +2,42 @@ import { Button } from "@/components/ui/button"; import Tab from "@/components/ui/tab"; -import { closeTerminal, createTerminal } from "@/lib/terminal"; import { Terminal } from "@xterm/xterm"; import { Loader2, Plus, SquareTerminal, TerminalSquare } from "lucide-react"; -import { Socket } from "socket.io-client"; import { toast } from "sonner"; import EditorTerminal from "./terminal"; -import { useState } from "react"; +import { useTerminal } from "@/context/TerminalContext"; +import { useEffect } from "react"; + +export default function Terminals() { + const { + terminals, + setTerminals, + socket, + createNewTerminal, + closeTerminal, + activeTerminalId, + setActiveTerminalId, + creatingTerminal, + } = useTerminal(); -export default function Terminals({ - terminals, - setTerminals, - socket, -}: { - terminals: { id: string; terminal: Terminal | null }[]; - setTerminals: React.Dispatch< - React.SetStateAction< - { - id: string; - terminal: Terminal | null; - }[] - > - >; - socket: Socket; -}) { - const [activeTerminalId, setActiveTerminalId] = useState(""); - const [creatingTerminal, setCreatingTerminal] = useState(false); - const [closingTerminal, setClosingTerminal] = useState(""); const activeTerminal = terminals.find((t) => t.id === activeTerminalId); + // Effect to set the active terminal when a new one is created + useEffect(() => { + if (terminals.length > 0 && !activeTerminalId) { + setActiveTerminalId(terminals[terminals.length - 1].id); + } + }, [terminals, activeTerminalId, setActiveTerminalId]); + + const handleCreateTerminal = () => { + if (terminals.length >= 4) { + toast.error("You reached the maximum # of terminals."); + return; + } + createNewTerminal(); + }; + return ( <>
@@ -39,18 +46,7 @@ export default function Terminals({ key={term.id} creating={creatingTerminal} onClick={() => setActiveTerminalId(term.id)} - onClose={() => - closeTerminal({ - term, - terminals, - setTerminals, - setActiveTerminalId, - setClosingTerminal, - socket, - activeTerminalId, - }) - } - closing={closingTerminal === term.id} + onClose={() => closeTerminal(term.id)} selected={activeTerminalId === term.id} > @@ -59,18 +55,7 @@ export default function Terminals({ ))}