mirror of
https://github.com/khairul169/code-share.git
synced 2025-04-28 08:39:35 +07:00
feat: add sandbox
This commit is contained in:
parent
5b68230734
commit
85e71c2983
20
hooks/useSSE.ts
Normal file
20
hooks/useSSE.ts
Normal file
@ -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]);
|
||||
};
|
25
lib/api.ts
Normal file
25
lib/api.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
182
pages/project/@slug/components/api-manager.tsx
Normal file
182
pages/project/@slug/components/api-manager.tsx
Normal file
@ -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 (
|
||||
<div className="p-8 h-full flex flex-col items-center justify-center">
|
||||
<Spinner />
|
||||
<p>
|
||||
{start.isPending
|
||||
? "Starting up development sandbox..."
|
||||
: "Please wait..."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats.data || start.isError) {
|
||||
return (
|
||||
<div className="p-8 h-full flex flex-col items-center justify-center">
|
||||
<p>Cannot load dev sandbox :(</p>
|
||||
{start.error?.message ? (
|
||||
<p className="text-sm mt-2">{start.error.message}</p>
|
||||
) : null}
|
||||
|
||||
<Button onClick={onRetry} className="mt-4">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-2 h-full flex flex-col">
|
||||
<div className="flex gap-4 items-start">
|
||||
<Stats data={stats.data.result} />
|
||||
<Actions stats={stats} />
|
||||
</div>
|
||||
<Divider className="my-2" />
|
||||
|
||||
<p className="text-sm mb-1">Output:</p>
|
||||
<Logs />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => restart.mutate()}
|
||||
isLoading={restart.isPending}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<ActionButton
|
||||
icon={FaCopy}
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => copy(proxyUrl)}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={FaExternalLinkAlt}
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => window.open(getUrl(proxyUrl), "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Stats = ({ data }: any) => {
|
||||
const { cpu, mem, memUsage, network, status, addr } = data;
|
||||
const [memUsed, memTotal] = memUsage || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-sm flex-1">
|
||||
<p>Status: {status}</p>
|
||||
<p>Address: {addr}</p>
|
||||
<p>CPU: {cpu}%</p>
|
||||
<p>
|
||||
Memory: {memUsed != null ? `${memUsed} / ${memTotal} (${mem}%)` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="w-full flex-1 shrink-0 bg-gray-900 p-4 overflow-y-auto flex flex-col-reverse gap-2 rounded-lg text-sm relative">
|
||||
<ActionButton
|
||||
icon={FaTimes}
|
||||
className="absolute top-1 right-2"
|
||||
onClick={() => setLogs([])}
|
||||
/>
|
||||
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="border-t last:border-t-0 border-t-gray-800 pt-2"
|
||||
>
|
||||
{log.log.split("\n").map((line, idx) => (
|
||||
<Ansi key={idx} className="block">
|
||||
{line}
|
||||
</Ansi>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default APIManager;
|
@ -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<CreateFileSchema>;
|
||||
@ -33,6 +34,7 @@ const defaultValues: z.infer<typeof fileSchema> = {
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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<Data>();
|
||||
@ -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: <FiServer />,
|
||||
render: () => <APIManager />,
|
||||
locked: true,
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}, [curOpenFiles, openedFiles, breakpoint]);
|
||||
|
||||
|
@ -214,6 +214,7 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
return updateFile.mutate({
|
||||
projectId: file.projectId,
|
||||
id: file.id,
|
||||
isPinned: !file.isPinned,
|
||||
});
|
||||
|
@ -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<Data>();
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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) => {
|
||||
>
|
||||
<FaRedo />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="dark:hover:bg-slate-700"
|
||||
onClick={() => window.open(url || "#", "_blank")}
|
||||
>
|
||||
<FaExternalLinkAlt />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{url != null ? (
|
||||
|
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
170
server/api/sandbox.ts
Normal file
170
server/api/sandbox.ts
Normal file
@ -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;
|
@ -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();
|
||||
|
66
server/lib/sandbox.ts
Normal file
66
server/lib/sandbox.ts
Normal file
@ -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;
|
@ -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<UnpackProjectOptions> = {}
|
||||
) => {
|
||||
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 });
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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<typeof insertFileSchema> = {
|
||||
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" });
|
||||
|
Loading…
x
Reference in New Issue
Block a user