diff --git a/hooks/useSSE.ts b/hooks/useSSE.ts new file mode 100644 index 0000000..a9de1b3 --- /dev/null +++ b/hooks/useSSE.ts @@ -0,0 +1,20 @@ +import { useEffect } from "react"; + +export const useSSE = (url: string, onData: (data: any) => void) => { + useEffect(() => { + const sse = new EventSource(url, { withCredentials: true }); + + sse.onmessage = (e) => { + onData(JSON.parse(e.data)); + }; + + sse.onerror = () => { + console.log("err!"); + sse.close(); + }; + + return () => { + sse.close(); + }; + }, [url]); +}; diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..1bb2c5e --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,25 @@ +import { BASE_URL } from "./consts"; + +export const api = async (url: string, options?: RequestInit) => { + const res = await fetch(BASE_URL + "/api" + url, options); + if (!res.ok) { + const body = await res.text().catch(() => null); + throw new APIError(body || res.statusText, res.status); + } + + const contentType = res.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return res.json(); + } + + return res.text(); +}; + +export class APIError extends Error { + code: number; + + constructor(message: string, statusCode?: number) { + super(message); + this.code = typeof statusCode === "number" ? statusCode : 400; + } +} diff --git a/package.json b/package.json index 3f768b9..58f7b85 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "tsx server/index.ts", + "dev": "HOST=0.0.0.0 tsx server/index.ts", "dev:watch": "tsx watch --ignore *.mjs server/index.ts", "start": "NODE_ENV=production tsx server/index.ts", "build": "vite build", @@ -58,6 +58,7 @@ "@trpc/server": "11.0.0-next-beta.289", "@uiw/codemirror-theme-tokyo-night": "^4.21.22", "@uiw/react-codemirror": "^4.21.22", + "ansi-to-react": "^6.1.6", "bcrypt": "^5.1.1", "better-sqlite3": "^9.4.1", "class-variance-authority": "^0.7.0", @@ -73,6 +74,7 @@ "jsonwebtoken": "^9.0.2", "lucide-react": "^0.331.0", "mime": "^4.0.1", + "node-fetch": "^3.3.2", "nprogress": "^0.2.0", "postcss": "^8", "prettier": "^3.2.5", diff --git a/pages/project/@slug/components/api-manager.tsx b/pages/project/@slug/components/api-manager.tsx new file mode 100644 index 0000000..52b174f --- /dev/null +++ b/pages/project/@slug/components/api-manager.tsx @@ -0,0 +1,182 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import Ansi from "ansi-to-react"; +import { createId } from "@paralleldrive/cuid2"; +import { useProjectContext } from "../context/project"; +import { api } from "~/lib/api"; +import Spinner from "~/components/ui/spinner"; +import { useEffect, useState } from "react"; +import { BASE_URL } from "~/lib/consts"; +import { useSSE } from "~/hooks/useSSE"; +import Divider from "~/components/ui/divider"; +import { Button } from "~/components/ui/button"; +import { FaCopy, FaExternalLinkAlt, FaTimes } from "react-icons/fa"; +import ActionButton from "~/components/ui/action-button"; +import { copy, getUrl } from "~/lib/utils"; + +const APIManager = () => { + const { project } = useProjectContext(); + + const stats = useQuery({ + queryKey: ["sandbox/stats", project.slug], + queryFn: () => api(`/sandbox/${project.slug}/stats`), + refetchInterval: 5000, + retry: false, + }); + + const start = useMutation({ + mutationFn: () => api(`/sandbox/${project.slug}/start`, { method: "POST" }), + onSuccess: () => stats.refetch(), + }); + + useEffect(() => { + if (stats.error && (stats.error as any).code === 404 && start.isIdle) { + start.mutate(); + } + }, [stats.error, start.isIdle]); + + const onRetry = () => { + if (start.isError) { + start.mutate(); + } else if (!stats.data) { + stats.refetch(); + } + }; + + if (stats.isLoading || start.isPending) { + return ( +
+ +

+ {start.isPending + ? "Starting up development sandbox..." + : "Please wait..."} +

+
+ ); + } + + if (!stats.data || start.isError) { + return ( +
+

Cannot load dev sandbox :(

+ {start.error?.message ? ( +

{start.error.message}

+ ) : null} + + +
+ ); + } + + return ( +
+
+ + +
+ + +

Output:

+ +
+ ); +}; + +const Actions = ({ stats }: any) => { + const { project } = useProjectContext(); + const restart = useMutation({ + mutationFn: () => { + return api(`/sandbox/${project.slug}/restart`, { method: "POST" }); + }, + onSuccess: () => stats.refetch(), + }); + const proxyUrl = `/api/sandbox/${project.slug}/proxy`; + + return ( +
+ + copy(proxyUrl)} + /> + window.open(getUrl(proxyUrl), "_blank")} + /> +
+ ); +}; + +const Stats = ({ data }: any) => { + const { cpu, mem, memUsage, network, status, addr } = data; + const [memUsed, memTotal] = memUsage || []; + + return ( +
+

Status: {status}

+

Address: {addr}

+

CPU: {cpu}%

+

+ Memory: {memUsed != null ? `${memUsed} / ${memTotal} (${mem}%)` : "-"} +

+
+ ); +}; + +const Logs = () => { + const { project } = useProjectContext(); + const url = BASE_URL + `/api/sandbox/${project.slug}/logs`; + const [logs, setLogs] = useState<{ log: string; time: number; id: string }[]>( + [] + ); + + function onData(data: any) { + setLogs((l) => [ + { ...data, log: data.log.replace(/[^\x00-\x7F]/g, ""), id: createId() }, + ...l, + ]); + } + + useSSE(url, onData); + useEffect(() => { + setLogs([]); + }, [url]); + + return ( +
+ setLogs([])} + /> + + {logs.map((log) => ( +
+ {log.log.split("\n").map((line, idx) => ( + + {line} + + ))} +
+ ))} +
+ ); +}; + +export default APIManager; diff --git a/pages/project/@slug/components/createfile-dialog.tsx b/pages/project/@slug/components/createfile-dialog.tsx index 624998b..3559749 100644 --- a/pages/project/@slug/components/createfile-dialog.tsx +++ b/pages/project/@slug/components/createfile-dialog.tsx @@ -13,6 +13,7 @@ import FormErrorMessage from "../../../../components/ui/form-error-message"; import trpc from "~/lib/trpc"; import type { FileSchema } from "~/server/db/schema/file"; import { useWatch } from "react-hook-form"; +import { useProjectContext } from "../context/project"; type Props = { disclose: UseDiscloseReturn; @@ -33,6 +34,7 @@ const defaultValues: z.infer = { }; const CreateFileDialog = ({ disclose, onSuccess }: Props) => { + const { project } = useProjectContext(); const form = useForm(fileSchema, disclose.data || defaultValues); const isDir = useWatch({ name: "isDirectory", control: form.control }); @@ -55,9 +57,9 @@ const CreateFileDialog = ({ disclose, onSuccess }: Props) => { } if (values.id) { - update.mutate({ id: values.id!, ...values }); + update.mutate({ ...values, id: values.id!, projectId: project.id }); } else { - create.mutate(values); + create.mutate({ ...values, projectId: project.id }); } }); diff --git a/pages/project/@slug/components/editor.tsx b/pages/project/@slug/components/editor.tsx index b7c66e6..c9f6b05 100644 --- a/pages/project/@slug/components/editor.tsx +++ b/pages/project/@slug/components/editor.tsx @@ -17,9 +17,11 @@ import { useData } from "~/renderer/hooks"; import { Data } from "../+data"; import { useBreakpoint } from "~/hooks/useBreakpoint"; import StatusBar from "./status-bar"; -import { FiTerminal } from "react-icons/fi"; +import { FiServer, FiTerminal } from "react-icons/fi"; import SettingsDialog from "./settings-dialog"; import FileIcon from "~/components/ui/file-icon"; +import APIManager from "./api-manager"; +import { api } from "~/lib/api"; const Editor = () => { const { project, initialFiles } = useData(); @@ -71,6 +73,11 @@ const Editor = () => { } }, [openedFilesData.data]); + useEffect(() => { + // start API sandbox + api(`/sandbox/${project.slug}/start`, { method: "POST" }).catch(() => {}); + }, [project]); + const onOpenFile = useCallback( (fileId: number, autoSwitchTab = true) => { const idx = curOpenFiles.indexOf(fileId); @@ -147,6 +154,13 @@ const Editor = () => { }); } + tabs.push({ + title: "API", + icon: , + render: () => , + locked: true, + }); + return tabs; }, [curOpenFiles, openedFiles, breakpoint]); diff --git a/pages/project/@slug/components/file-listing.tsx b/pages/project/@slug/components/file-listing.tsx index ac80efa..6668db9 100644 --- a/pages/project/@slug/components/file-listing.tsx +++ b/pages/project/@slug/components/file-listing.tsx @@ -214,6 +214,7 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => { { return updateFile.mutate({ + projectId: file.projectId, id: file.id, isPinned: !file.isPinned, }); diff --git a/pages/project/@slug/components/file-viewer.tsx b/pages/project/@slug/components/file-viewer.tsx index 86009b9..e9a2534 100644 --- a/pages/project/@slug/components/file-viewer.tsx +++ b/pages/project/@slug/components/file-viewer.tsx @@ -5,12 +5,14 @@ import { useData } from "~/renderer/hooks"; import { Data } from "../+data"; import Spinner from "~/components/ui/spinner"; import { previewStore } from "../stores/web-preview"; +import { useProjectContext } from "../context/project"; type Props = { id: number; }; const FileViewer = ({ id }: Props) => { + const { project } = useProjectContext(); const { initialFiles } = useData(); const initialData = initialFiles.find((i) => i.id === id); @@ -48,7 +50,9 @@ const FileViewer = ({ id }: Props) => { lang={ext} value={data?.content || ""} formatOnSave - onChange={(val) => updateFileContent.mutate({ id, content: val })} + onChange={(val) => + updateFileContent.mutate({ projectId: project.id, id, content: val }) + } /> ); } diff --git a/pages/project/@slug/components/web-preview.tsx b/pages/project/@slug/components/web-preview.tsx index 88b1361..c744eff 100644 --- a/pages/project/@slug/components/web-preview.tsx +++ b/pages/project/@slug/components/web-preview.tsx @@ -3,7 +3,7 @@ import Panel from "~/components/ui/panel"; import { ComponentProps, useCallback, useEffect, useRef } from "react"; import { useProjectContext } from "../context/project"; import { Button } from "~/components/ui/button"; -import { FaRedo } from "react-icons/fa"; +import { FaExternalLinkAlt, FaRedo } from "react-icons/fa"; import { previewStore } from "../stores/web-preview"; import { ImperativePanelHandle } from "react-resizable-panels"; import useCommandKey from "~/hooks/useCommandKey"; @@ -75,6 +75,13 @@ const WebPreview = ({ url, ...props }: WebPreviewProps) => { > + {url != null ? ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43f01b2..b540456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ dependencies: '@uiw/react-codemirror': specifier: ^4.21.22 version: 4.21.22(@babel/runtime@7.23.9)(@codemirror/autocomplete@6.12.0)(@codemirror/language@6.10.1)(@codemirror/lint@6.5.0)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.24.1)(codemirror@6.0.1)(react-dom@18.2.0)(react@18.2.0) + ansi-to-react: + specifier: ^6.1.6 + version: 6.1.6(react-dom@18.2.0)(react@18.2.0) bcrypt: specifier: ^5.1.1 version: 5.1.1 @@ -107,6 +110,9 @@ dependencies: mime: specifier: ^4.0.1 version: 4.0.1 + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -2521,6 +2527,10 @@ packages: - supports-color dev: false + /anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + dev: false + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2545,6 +2555,18 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + /ansi-to-react@6.1.6(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-+HWn72GKydtupxX9TORBedqOMsJRiKTqaLUKW8txSBZw9iBpzPKLI8KOu4WzwD4R7hSv1zEspobY6LwlWvwZ6Q==} + peerDependencies: + react: ^16.3.2 || ^17.0.0 + react-dom: ^16.3.2 || ^17.0.0 + dependencies: + anser: 1.4.10 + escape-carriage: 1.3.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -3282,6 +3304,11 @@ packages: type: 1.2.0 dev: true + /data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dev: false + /data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -3727,6 +3754,10 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + /escape-carriage@1.3.1: + resolution: {integrity: sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==} + dev: false + /escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} dev: false @@ -3919,6 +3950,14 @@ packages: pend: 1.2.0 dev: false + /fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + dev: false + /file-type@17.1.6: resolution: {integrity: sha512-hlDw5Ev+9e883s0pwUsuuYNu4tD7GgpUnOvykjv1Gya0ZIjuKumthDRua90VUn6/nlRKAjcxLUnHNTIUWwWIiw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3985,6 +4024,13 @@ packages: cross-spawn: 7.0.3 signal-exit: 4.1.0 + /formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: false + /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -4873,6 +4919,11 @@ packages: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} dev: false + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -4885,6 +4936,15 @@ packages: whatwg-url: 5.0.0 dev: false + /node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + dev: false + /node-gyp-build@4.8.0: resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==} hasBin: true @@ -6556,6 +6616,11 @@ packages: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} dev: false + /web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + dev: false + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false diff --git a/server/api/index.ts b/server/api/index.ts index bcf2810..a4c7f98 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -2,11 +2,13 @@ import { Router } from "express"; import preview from "./preview"; import trpc from "./trpc/handler"; import { thumbnail } from "./thumbnail"; +import sandbox from "./sandbox"; const api = Router(); api.use("/trpc", trpc); api.use("/preview", preview); +api.use("/sandbox", sandbox); api.get("/thumbnail/:slug", thumbnail); diff --git a/server/api/preview/index.ts b/server/api/preview/index.ts index 8330022..d245196 100644 --- a/server/api/preview/index.ts +++ b/server/api/preview/index.ts @@ -45,7 +45,7 @@ const get = async (req: Request, res: Response) => { } if (["css"].includes(ext) && settings.css?.preprocessor === "postcss") { - content = await postcss(fileData, settings.css); + content = await postcss(projectData, fileData); } } diff --git a/server/api/preview/postcss.ts b/server/api/preview/postcss.ts index 92f9e87..1373ab1 100644 --- a/server/api/preview/postcss.ts +++ b/server/api/preview/postcss.ts @@ -3,16 +3,16 @@ import tailwindcss from "tailwindcss"; import cssnano from "cssnano"; import { FileSchema } from "~/server/db/schema/file"; import { unpackProject } from "~/server/lib/unpack-project"; -import { ProjectSettingsSchema } from "~/server/db/schema/project"; +import { ProjectSchema } from "~/server/db/schema/project"; -export const postcss = async ( - fileData: FileSchema, - cfg?: ProjectSettingsSchema["css"] -) => { +export const postcss = async (project: ProjectSchema, fileData: FileSchema) => { const content = fileData.content || ""; + const cfg = project.settings?.css; try { - const projectDir = await unpackProject({ ext: "ts,tsx,js,jsx,html" }); + const projectDir = await unpackProject(project, { + ext: "ts,tsx,js,jsx,html", + }); const plugins: any[] = []; if (cfg?.tailwindcss) { diff --git a/server/api/sandbox.ts b/server/api/sandbox.ts new file mode 100644 index 0000000..bcf6666 --- /dev/null +++ b/server/api/sandbox.ts @@ -0,0 +1,170 @@ +import { Router, type Request } from "express"; +import sandbox from "../lib/sandbox"; +import db from "~/server/db"; +import { and, eq, isNull } from "drizzle-orm"; +import { project } from "~/server/db/schema/project"; +import { unpackProject } from "../lib/unpack-project"; +import { APIError } from "~/lib/api"; + +const router = Router(); + +router.post("/:slug/start", async (req, res) => { + const { slug } = req.params as any; + const data = await getProjectBySlug(slug); + if (!data) { + return res.status(404).send("Project not found!"); + } + + try { + const path = await unpackProject(data); + const result = await sandbox.start(data.slug, path + "/api"); + + if (!result.ok) { + const body = await result.text(); + throw new APIError(body, result.status); + } + + res.json({ success: true }); + } catch (err) { + const error = err as any; + res + .status(typeof error?.code === "number" ? error.code : 500) + .send(error?.message || "An error occured!"); + } +}); + +router.post("/:slug/stop", async (req, res) => { + const { slug } = req.params as any; + const data = await getProjectBySlug(slug); + if (!data) { + return res.status(404).send("Project not found!"); + } + + try { + await sandbox.stop(data.slug); + res.json({ success: true }); + } catch (err) { + res.status(500).send("An error occured!"); + } +}); + +router.post("/:slug/restart", async (req, res) => { + const { slug } = req.params as any; + const data = await getProjectBySlug(slug); + if (!data) { + return res.status(404).send("Project not found!"); + } + + try { + await unpackProject(data); + const result = await sandbox.restart(data.slug); + + if (!result.ok) { + const body = await result.text(); + throw new APIError(body, result.status); + } + + res.json({ success: true }); + } catch (err) { + const error = err as any; + res + .status(typeof error?.code === "number" ? error.code : 500) + .send(error?.message || "An error occured!"); + } +}); + +router.get("/:slug/stats", async (req, res) => { + const { slug } = req.params as any; + const data = await getProjectBySlug(slug); + if (!data) { + return res.status(404).send("Project not found!"); + } + + try { + const result = await sandbox.getStats(data.slug); + res.json({ success: true, result }); + } catch (err) { + const error = err as any; + res + .status(typeof error.code === "number" ? error.code : 500) + .send(error.message || "An error occured!"); + } +}); + +router.get("/:slug/logs", async (req, res) => { + const { slug } = req.params as any; + const data = await getProjectBySlug(slug); + if (!data) { + return res.status(404).send("Project not found!"); + } + + try { + const { body, headers } = await sandbox.getLogs(data.slug); + if (!body) { + throw new Error("No body response."); + } + + headers.forEach((value, key) => res.setHeader(key, value)); + body.pipe(res); + } catch (err) { + res.status(500).send("An error occured!"); + } +}); + +router.get("/:slug/proxy*", async (req, res) => { + const params = req.params as any; + const { slug } = params; + const pathname = params?.[0]; + const searchParams = new URLSearchParams(); + + Object.entries(req.query || {}).forEach(([key, value]) => { + searchParams.set(key, value as string); + }); + + const data = await getProjectBySlug(slug); + if (!data) { + return res.status(404).send("Project not found!"); + } + + try { + const { body, headers, status } = await sandbox.proxy(data.slug, { + pathname: pathname + "?" + searchParams.toString(), + headers: getHeadersFromReq(req), + }); + + if (!body) { + throw new Error("No body response."); + } + + headers.forEach((value, key) => res.setHeader(key, value)); + res.status(status); + body.pipe(res); + } catch (err) { + const error = err as any; + res + .status(typeof error?.code === "number" ? error.code : 500) + .send(error?.message || "An error occured!"); + } +}); + +function getHeadersFromReq(req: Request) { + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers.set(key, value); + } else if (Array.isArray(value)) { + value.forEach((val) => headers.append(key, val)); + } + } + + return headers; +} + +async function getProjectBySlug(slug: string) { + return db.query.project.findFirst({ + where: and(eq(project.slug, slug), isNull(project.deletedAt)), + }); +} + +export default router; diff --git a/server/index.ts b/server/index.ts index e992aa9..3a05f1a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { IS_DEV } from "./lib/consts"; import cookieParser from "cookie-parser"; import api from "./api"; import { authMiddleware } from "./middlewares/auth"; +import fetch from "node-fetch"; async function createServer() { const app = express(); diff --git a/server/lib/sandbox.ts b/server/lib/sandbox.ts new file mode 100644 index 0000000..2aa080b --- /dev/null +++ b/server/lib/sandbox.ts @@ -0,0 +1,66 @@ +import fetch, { RequestInit } from "node-fetch"; + +const createRequest = async ( + url: string, + options?: RequestInit & { ignoreError?: boolean } +) => { + const reqUrl = "http://127.0.0.1:8000" + url; + const response = await fetch(reqUrl, options); + + if (!response.ok && !options?.ignoreError) { + const err: any = Error(response.statusText); + err.code = response.status; + throw err; + } + + return response; +}; + +const start = async (id: string, path: string) => { + return createRequest("/start", { + body: JSON.stringify({ path, id }), + method: "POST", + ignoreError: true, + }); +}; + +const stop = async (id: string) => { + const res = await createRequest(`/stop/${id}`, { method: "POST" }); + return res.json(); +}; + +const restart = async (id: string) => { + return createRequest(`/restart/${id}`, { method: "POST", ignoreError: true }); +}; + +const getStats = async (id: string) => { + const res = await createRequest(`/stats/${id}`); + return res.json(); +}; + +const getLogs = async (id: string) => { + return createRequest(`/logs/${id}`); +}; + +type ProxyOptions = { + pathname: string; + headers: Headers; +}; + +const proxy = async (id: string, opt: ProxyOptions) => { + return createRequest(`/proxy/${id}` + opt.pathname, { + headers: opt.headers, + ignoreError: true, + }); +}; + +const sandbox = { + start, + stop, + restart, + getStats, + getLogs, + proxy, +}; + +export default sandbox; diff --git a/server/lib/unpack-project.ts b/server/lib/unpack-project.ts index 0ed12ef..6d86ce4 100644 --- a/server/lib/unpack-project.ts +++ b/server/lib/unpack-project.ts @@ -5,12 +5,14 @@ import db from "../db"; import { file } from "../db/schema/file"; import { fileExists, getProjectDir } from "./utils"; import { getFileExt } from "~/lib/utils"; +import { ProjectSchema } from "../db/schema/project"; type UnpackProjectOptions = { ext: string; }; export const unpackProject = async ( + projectData: ProjectSchema, opt: Partial = {} ) => { const files = await db.query.file.findMany({ @@ -21,7 +23,7 @@ export const unpackProject = async ( ), }); - const projectDir = getProjectDir(); + const projectDir = getProjectDir(projectData); if (!fileExists(projectDir)) { await fs.mkdir(projectDir, { recursive: true }); } diff --git a/server/lib/utils.ts b/server/lib/utils.ts index 9cab837..e63b8db 100644 --- a/server/lib/utils.ts +++ b/server/lib/utils.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import cuid2 from "@paralleldrive/cuid2"; +import { ProjectSchema } from "../db/schema/project"; export const fileExists = (path: string) => { try { @@ -11,8 +12,8 @@ export const fileExists = (path: string) => { } }; -export const getProjectDir = () => { - return path.resolve(process.cwd(), "storage/tmp/project1"); +export const getProjectDir = (project: ProjectSchema) => { + return path.resolve(process.cwd(), "storage/tmp", project.slug); }; export const uid = cuid2.init({ diff --git a/server/routers/file.ts b/server/routers/file.ts index 626560e..05d579f 100644 --- a/server/routers/file.ts +++ b/server/routers/file.ts @@ -38,6 +38,7 @@ const fileRouter = router({ create: procedure .input( insertFileSchema.pick({ + projectId: true, parentId: true, filename: true, isDirectory: true, @@ -57,7 +58,7 @@ const fileRouter = router({ } const data: z.infer = { - projectId: 1, + projectId: input.projectId, parentId: input.parentId, path: basePath + input.filename, filename: input.filename, @@ -69,10 +70,14 @@ const fileRouter = router({ }), update: procedure - .input(selectFileSchema.partial().required({ id: true })) + .input(selectFileSchema.partial().required({ id: true, projectId: true })) .mutation(async ({ input }) => { const fileData = await db.query.file.findFirst({ - where: and(eq(file.id, input.id), isNull(file.deletedAt)), + where: and( + eq(file.projectId, input.projectId), + eq(file.id, input.id), + isNull(file.deletedAt) + ), }); if (!fileData) { throw new TRPCError({ code: "NOT_FOUND" });