add worker service binding + inactivity detection

This commit is contained in:
Ishaan Dey 2024-05-06 21:29:25 -07:00
parent c5762d430c
commit 84c49f0d9d
8 changed files with 99 additions and 21 deletions

View File

@ -9,7 +9,7 @@ import { and, eq, sql } from "drizzle-orm";
export interface Env { export interface Env {
DB: D1Database; DB: D1Database;
RL: any; STORAGE: any;
} }
// https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1 // https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
@ -86,11 +86,15 @@ export default {
const sb = await db.insert(sandbox).values({ type, name, userId, visibility }).returning().get(); const sb = await db.insert(sandbox).values({ type, name, userId, visibility }).returning().get();
await fetch("https://storage.ishaan1013.workers.dev/api/init", { const initStorageRequest = new Request("https://storage.ishaan1013.workers.dev/api/init", {
method: "POST", method: "POST",
body: JSON.stringify({ sandboxId: sb.id, type }), body: JSON.stringify({ sandboxId: sb.id, type }),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
const initStorageRes = await env.STORAGE.fetch(initStorageRequest);
const initStorage = await initStorageRes.text();
console.log("initStorage: ", initStorage);
return new Response(sb.id, { status: 200 }); return new Response(sb.id, { status: 200 });
} else { } else {

View File

@ -36,14 +36,12 @@ const readAndParseKubeYaml = (
const regex = new RegExp(`<SANDBOX>`, "g") const regex = new RegExp(`<SANDBOX>`, "g")
docString = docString.replace(regex, sandboxId) docString = docString.replace(regex, sandboxId)
// replace <CF_API_TOKEN> with process.env.CF_API_TOKEN
if (!process.env.CF_API_TOKEN) { if (!process.env.CF_API_TOKEN) {
throw new Error("CF_API_TOKEN is not defined") throw new Error("CF_API_TOKEN is not defined")
} }
const regexEnv1 = new RegExp(`<CF_API_TOKEN>`, "g") const regexEnv1 = new RegExp(`<CF_API_TOKEN>`, "g")
docString = docString.replace(regexEnv1, process.env.CF_API_TOKEN) docString = docString.replace(regexEnv1, process.env.CF_API_TOKEN)
// replace <CF_USER_ID> with process.env.CF_USER_ID
if (!process.env.CF_USER_ID) { if (!process.env.CF_USER_ID) {
throw new Error("CF_USER_ID is not defined") throw new Error("CF_USER_ID is not defined")
} }
@ -55,12 +53,13 @@ const readAndParseKubeYaml = (
return docs return docs
} }
app.post("/start", async (req, res) => { const dataSchema = z.object({
const initSchema = z.object({
userId: z.string(), userId: z.string(),
sandboxId: z.string(), sandboxId: z.string(),
}) })
const { userId, sandboxId } = initSchema.parse(req.body)
app.post("/start", async (req, res) => {
const { userId, sandboxId } = dataSchema.parse(req.body)
const namespace = "default" const namespace = "default"
try { try {
@ -83,6 +82,39 @@ app.post("/start", async (req, res) => {
} }
}) })
app.post("/stop", async (req, res) => {
const { userId, sandboxId } = dataSchema.parse(req.body)
const namespace = "default"
try {
const kubeManifests = readAndParseKubeYaml(
path.join(__dirname, "../service.yaml"),
sandboxId
)
kubeManifests.forEach(async (manifest) => {
if (manifest.kind === "Deployment")
await appsV1Api.deleteNamespacedDeployment(
manifest.metadata?.name || "",
namespace
)
else if (manifest.kind === "Service")
await coreV1Api.deleteNamespacedService(
manifest.metadata?.name || "",
namespace
)
else if (manifest.kind === "Ingress")
await networkingV1Api.deleteNamespacedIngress(
manifest.metadata?.name || "",
namespace
)
})
res.status(200).send({ message: "Resources deleted." })
} catch (error) {
console.log("Failed to delete resources", error)
res.status(500).send({ message: "Failed to delete resources." })
}
})
app.listen(port, () => { app.listen(port, () => {
console.log(`Listening on port: ${port}`) console.log(`Listening on port: ${port}`)
}) })

View File

@ -1,5 +1,5 @@
ARG cf_api_token ARG CF_API_TOKEN
ARG cf_user_id ARG CF_USER_ID
FROM node:20 FROM node:20
@ -15,7 +15,7 @@ RUN npm run build
EXPOSE 3000 EXPOSE 3000
ENV cf_api_token=$cf_api_token ENV CF_API_TOKEN=$CF_API_TOKEN
ENV cf_user_id=$cf_user_id ENV CF_USER_ID=$CF_USER_ID
CMD [ "node", "dist/index.js" ] CMD [ "node", "dist/index.js" ]

View File

@ -0,0 +1,10 @@
import { Server } from "socket.io";
import { DefaultEventsMap } from "socket.io/dist/typed-events";
export function checkForInactivity(io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>) {
io.fetchSockets().then(sockets => {
if (sockets.length === 0) {
console.log("No users have been connected for 15 seconds.");
}
});
}

View File

@ -38,6 +38,8 @@ const io = new Server(httpServer, {
}, },
}) })
let inactivityTimeout: NodeJS.Timeout | null = null;
const terminals: { const terminals: {
[id: string]: { terminal: IPty; onData: IDisposable; onExit: IDisposable } [id: string]: { terminal: IPty; onData: IDisposable; onExit: IDisposable }
} = {} } = {}
@ -85,15 +87,20 @@ io.use(async (socket, next) => {
socket.data = { socket.data = {
userId, userId,
sandboxId: sandboxId, sandboxId: sandboxId,
isOwner: sandbox !== undefined,
} }
next() next()
}) })
io.on("connection", async (socket) => { io.on("connection", async (socket) => {
if (inactivityTimeout) clearTimeout(inactivityTimeout);
const data = socket.data as { const data = socket.data as {
userId: string userId: string
sandboxId: string sandboxId: string
isOwner: boolean
} }
const sandboxFiles = await getSandboxFiles(data.sandboxId) const sandboxFiles = await getSandboxFiles(data.sandboxId)
@ -298,7 +305,8 @@ io.on("connection", async (socket) => {
} }
) )
socket.on("disconnect", () => { socket.on("disconnect", async () => {
if (data.isOwner) {
Object.entries(terminals).forEach((t) => { Object.entries(terminals).forEach((t) => {
const { terminal, onData, onExit } = t[1] const { terminal, onData, onExit } = t[1]
if (os.platform() !== "win32") terminal.kill() if (os.platform() !== "win32") terminal.kill()
@ -306,6 +314,28 @@ io.on("connection", async (socket) => {
onExit.dispose() onExit.dispose()
delete terminals[t[0]] delete terminals[t[0]]
}) })
console.log("The owner disconnected.")
socket.broadcast.emit("ownerDisconnected")
}
else {
console.log("A shared user disconnected.")
}
const sockets = await io.fetchSockets()
if (inactivityTimeout) {
clearTimeout(inactivityTimeout)
};
if (sockets.length === 0) {
inactivityTimeout = setTimeout(() => {
io.fetchSockets().then(sockets => {
if (sockets.length === 0) {
console.log("No users have been connected for 15 seconds.");
}
});
}, 15000);
}
}) })
}) })

View File

@ -60,7 +60,8 @@ const data: {
] ]
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(1).max(16), name: z.string().min(1).max(16)
.refine((value) => /^[a-zA-Z0-9_]+$/.test(value), "Name must be alphanumeric and can contain underscores"),
visibility: z.enum(["public", "private"]), visibility: z.enum(["public", "private"]),
}) })

View File

@ -23,7 +23,7 @@ export default function UserButton({ userData }: { userData: User }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<div className="w-9 h-9 font-mono rounded-full overflow-hidden bg-gradient-to-t from-neutral-800 to-neutral-600 flex items-center justify-center text-sm font-medium"> <div className="w-9 h-9 font-mono rounded-full overflow-hidden bg-gradient-to-t from-neutral-800 to-neutral-600 flex items-center justify-center text-sm font-medium">
{userData.name {userData.name && userData.name
.split(" ") .split(" ")
.slice(0, 2) .slice(0, 2)
.map((name) => name[0].toUpperCase())} .map((name) => name[0].toUpperCase())}

View File

@ -8,6 +8,7 @@ export async function createSandbox(body: {
userId: string userId: string
visibility: string visibility: string
}) { }) {
console.log("creating. body:", body)
const res = await fetch( const res = await fetch(
"https://database.ishaan1013.workers.dev/api/sandbox", "https://database.ishaan1013.workers.dev/api/sandbox",
{ {