diff --git a/backend/database.db b/backend/database.db index 4e03bad..030f205 100644 Binary files a/backend/database.db and b/backend/database.db differ diff --git a/backend/src/chat/chat.model.ts b/backend/src/chat/chat.model.ts index f782dce..ef94c81 100644 --- a/backend/src/chat/chat.model.ts +++ b/backend/src/chat/chat.model.ts @@ -48,10 +48,6 @@ export class Chat extends SystemBaseModel { }) messages: Message[]; - @Field(() => ID) - @Column() - projectId: string; - @ManyToOne(() => User, (user) => user.chats, { onDelete: 'CASCADE', nullable: false, diff --git a/backend/src/project/project.model.ts b/backend/src/project/project.model.ts index f493702..83b585f 100644 --- a/backend/src/project/project.model.ts +++ b/backend/src/project/project.model.ts @@ -9,6 +9,7 @@ import { ManyToMany, JoinTable, OneToMany, + RelationId, } from 'typeorm'; import { User } from 'src/user/user.model'; import { ProjectPackages } from './project-packages.model'; @@ -30,7 +31,8 @@ export class Project extends SystemBaseModel { projectPath: string; @Field(() => ID) - @Column() + @RelationId((project: Project) => project.user) + @Column({ name: 'user_id' }) userId: string; @ManyToOne(() => User, (user) => user.projects, { diff --git a/backend/template/react-ts/Dockerfile b/backend/template/react-ts/Dockerfile new file mode 100644 index 0000000..76508c5 --- /dev/null +++ b/backend/template/react-ts/Dockerfile @@ -0,0 +1,15 @@ +FROM node:18 + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install --frozen-lockfile + +COPY . . + +RUN chmod +x /app/node_modules/.bin/vite + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/backend/template/react-ts/vite.config.ts b/backend/template/react-ts/vite.config.ts index 09bfddc..97ca870 100644 --- a/backend/template/react-ts/vite.config.ts +++ b/backend/template/react-ts/vite.config.ts @@ -15,5 +15,19 @@ export default defineConfig({ }, build: { target: 'esnext', // Ensure Vite compiles for a modern target + sourcemap: false, + rollupOptions: { + output: { + manualChunks: undefined, // avoid sending code by chunk + }, + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + watch: { + usePolling: true, + }, }, }); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8b3ff43 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3' + +services: + reverse-proxy: + # The official v3 Traefik docker image + image: traefik:v3.3 + # Enables the web UI and tells Traefik to listen to docker + command: + - '--api.insecure=true' + - '--providers.docker' + - '--entrypoints.web.address=:80' + ports: + # The HTTP port + - '80:80' + # The Web UI (enabled by --api.insecure=true) + - '9001:8080' + volumes: + # So that Traefik can listen to the Docker events + - /var/run/docker.sock:/var/run/docker.sock + networks: + - traefik_network + +networks: + traefik_network: + external: true diff --git a/frontend/src/app/api/runProject/route.ts b/frontend/src/app/api/runProject/route.ts new file mode 100644 index 0000000..8be2fb3 --- /dev/null +++ b/frontend/src/app/api/runProject/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server'; +import { exec } from 'child_process'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as net from 'net'; +import { getProjectPath } from 'codefox-common'; + +const runningContainers = new Map< + string, + { domain: string; containerId: string } +>(); +const allocatedPorts = new Set(); + +function findAvailablePort( + minPort: number = 38000, + maxPort: number = 42000 +): Promise { + return new Promise((resolve, reject) => { + function checkPort(port: number): Promise { + return new Promise((resolve) => { + if (allocatedPorts.has(port)) { + return resolve(false); + } + const server = net.createServer(); + server.listen(port, '127.0.0.1', () => { + server.close(() => resolve(true)); + }); + server.on('error', () => resolve(false)); + }); + } + + async function scanPorts() { + for (let port = minPort; port <= maxPort; port++) { + if (await checkPort(port)) { + allocatedPorts.add(port); + return resolve(port); + } + } + reject(new Error('No available ports found.')); + } + + scanPorts(); + }); +} + +async function buildAndRunDocker( + projectPath: string +): Promise<{ domain: string; containerId: string }> { + console.log(runningContainers); + if (runningContainers.has(projectPath)) { + console.log(`Container for project ${projectPath} is already running.`); + return runningContainers.get(projectPath)!; + } + const traefikDomain = process.env.TRAEFIK_DOMAIN || 'docker.localhost'; + const directory = path.join(getProjectPath(projectPath), 'frontend'); + const imageName = projectPath.toLowerCase(); + const containerId = crypto.randomUUID(); + const containerName = `container-${containerId}`; + + const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); + const domain = `${subdomain}.${traefikDomain}`; + const exposedPort = await findAvailablePort(); + return new Promise((resolve, reject) => { + exec( + `docker build -t ${imageName} ${directory}`, + (buildErr, buildStdout, buildStderr) => { + if (buildErr) { + console.error(`Error during Docker build: ${buildStderr}`); + return reject(buildErr); + } + + console.log(`Docker build output:\n${buildStdout}`); + console.log(`Running Docker container: ${containerName}`); + + const runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \ + -l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \ + -l "traefik.http.services.${subdomain}.loadbalancer.server.port=5173" \ + --network=traefik_network -p ${exposedPort}:5173 ${imageName}`; + console.log(runCommand); + + exec(runCommand, (runErr, runStdout, runStderr) => { + if (runErr) { + console.error(`Error during Docker run: ${runStderr}`); + return reject(runErr); + } + + const containerActualId = runStdout.trim(); + runningContainers.set(projectPath, { + domain, + containerId: containerActualId, + }); + + console.log( + `Container ${containerName} is now running at http://${domain}` + ); + + resolve({ domain, containerId: containerActualId }); + }); + } + ); + }); +} +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const projectPath = searchParams.get('projectPath'); + + if (!projectPath) { + return NextResponse.json( + { error: 'Missing required parameters' }, + { status: 400 } + ); + } + + try { + const { domain, containerId } = await buildAndRunDocker(projectPath); + return NextResponse.json({ + message: 'Docker container started', + domain, + containerId, + }); + } catch (error) { + return NextResponse.json( + { error: error.message || 'Failed to start Docker container' }, + { status: 500 } + ); + } +} diff --git a/frontend/src/components/code-engine/code-engine.tsx b/frontend/src/components/code-engine/code-engine.tsx index b7a2873..451fda3 100644 --- a/frontend/src/components/code-engine/code-engine.tsx +++ b/frontend/src/components/code-engine/code-engine.tsx @@ -18,6 +18,7 @@ import { TreeItem, TreeItemIndex } from 'react-complex-tree'; import FileExplorerButton from './file-explorer-button'; import FileStructure from './file-structure'; import { ProjectContext } from './project-context'; +import WebPreview from './web-view'; export function CodeEngine({ chatId }: { chatId: string }) { // Initialize state, refs, and context @@ -314,7 +315,7 @@ export function CodeEngine({ chatId }: { chatId: string }) { ) : activeTab === 'preview' ? ( -
Preview Content (Mock)
+ ) : activeTab === 'console' ? (
Console Content (Mock)
) : null} diff --git a/frontend/src/components/code-engine/project-context.tsx b/frontend/src/components/code-engine/project-context.tsx index 7010157..44cc81f 100644 --- a/frontend/src/components/code-engine/project-context.tsx +++ b/frontend/src/components/code-engine/project-context.tsx @@ -34,7 +34,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const [chatProjectCache, setChatProjectCache] = useState< Map >(new Map()); - const MAX_RETRIES = 20; + const MAX_RETRIES = 100; useQuery(GET_USER_PROJECTS, { onCompleted: (data) => setProjects(data.getUserProjects), @@ -106,7 +106,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { console.error('Error polling chat:', error); } - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 6000)); retries++; } diff --git a/frontend/src/components/code-engine/web-view.tsx b/frontend/src/components/code-engine/web-view.tsx new file mode 100644 index 0000000..b27d7ea --- /dev/null +++ b/frontend/src/components/code-engine/web-view.tsx @@ -0,0 +1,84 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { ProjectContext } from './project-context'; + +export default function WebPreview() { + const { curProject } = useContext(ProjectContext); + const [url, setUrl] = useState(''); + const iframeRef = useRef(null); + + useEffect(() => { + const getWebUrl = async () => { + const projectPath = curProject.projectPath; + try { + const response = await fetch( + `/api/runProject?projectPath=${encodeURIComponent(projectPath)}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + const json = await response.json(); + console.log(json); + await new Promise((resolve) => setTimeout(resolve, 10000)); + setUrl(`http://${json.domain}/`); + } catch (error) { + console.error('fetching url error:', error); + } + }; + + getWebUrl(); + }, [curProject]); + + useEffect(() => { + if (iframeRef.current) { + iframeRef.current.src = url; + } + }, [url]); + + const refreshIframe = () => { + if (iframeRef.current) { + iframeRef.current.src = url; + } + }; + + const enterFullScreen = () => { + if (iframeRef.current) { + iframeRef.current.requestFullscreen(); + } + }; + + return ( +
+
+ setUrl(e.target.value)} + className="flex-1 p-2 border rounded" + /> + + +
+ +
+