aethex-forge/api/stream/session.ts
AeThex 2ae331f9fe fix: remove listGpus import/route breaking production build
listGpus was removed from session.ts when migrating to RunPod Serverless,
but server/index.ts still imported and registered it, failing the esbuild.
Also stages stream API, StreamUpgrade page, and Dockerfile/compose fixes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:42:44 +00:00

200 lines
6.5 KiB
TypeScript

import type { Request, Response } from "express";
import { createClient } from "@supabase/supabase-js";
const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";
const RUNPOD_ENDPOINT_ID = process.env.RUNPOD_ENDPOINT_ID || "";
const SIGNALING_URL = process.env.SIGNALING_URL || "wss://signal.aethex.tech/ws";
const TURN_SERVER = process.env.TURN_SERVER || "turn://aethex:changeme-turn-password@turn.aethex.tech:3478";
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE!
);
async function getUserFromToken(token: string) {
const { data: { user }, error } = await supabaseAdmin.auth.getUser(token);
if (error || !user) return null;
return user;
}
async function spawnJob(input: Record<string, string>) {
if (!RUNPOD_ENDPOINT_ID) throw new Error("RUNPOD_ENDPOINT_ID not configured");
const res = await fetch(`https://api.runpod.io/v2/${RUNPOD_ENDPOINT_ID}/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${RUNPOD_API_KEY}`,
},
body: JSON.stringify({ input }),
});
const data = await res.json() as any;
if (!res.ok || data.error) throw new Error(data.error || `RunPod error ${res.status}`);
return data as { id: string; status: string };
}
async function cancelJob(jobId: string) {
if (!RUNPOD_ENDPOINT_ID) return;
await fetch(`https://api.runpod.io/v2/${RUNPOD_ENDPOINT_ID}/cancel/${jobId}`, {
method: "POST",
headers: { "Authorization": `Bearer ${RUNPOD_API_KEY}` },
});
}
// POST /api/stream/session/start
export async function startSession(req: Request, res: Response) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
const user = await getUserFromToken(token);
if (!user) return res.status(401).json({ error: "Invalid token" });
const { mode = "game", game } = req.body as { mode?: string; game?: string };
const { data: existing } = await supabaseAdmin
.from("stream_sessions")
.select("id, pod_id")
.eq("user_id", user.id)
.in("status", ["active", "starting"])
.maybeSingle();
if (existing) {
return res.json({ sessionId: existing.id, jobId: existing.pod_id, alreadyActive: true });
}
if (mode === "devstation") {
const { data: profile } = await supabaseAdmin
.from("profiles")
.select("role")
.eq("id", user.id)
.maybeSingle();
if (!["developer", "admin", "oversee"].includes(profile?.role ?? "")) {
return res.status(403).json({ error: "Dev station access requires developer role" });
}
}
try {
const job = await spawnJob({
MODE: mode,
SIGNALING_URL,
TURN_SERVER,
AETHEX_USER_ID: user.id,
IDLE_TIMEOUT: "600",
MAX_SESSION: "7200",
...(game ? { GAME: game } : {}),
});
console.log(`[stream] serverless job spawned: ${job.id} status=${job.status}`);
const { data: session } = await supabaseAdmin
.from("stream_sessions")
.insert({
user_id: user.id,
pod_id: job.id,
mode,
game: game || null,
status: "starting",
})
.select("id")
.single();
res.json({ sessionId: session!.id, jobId: job.id, status: "starting" });
} catch (err: any) {
console.error("[stream/start] error:", err.message);
res.status(500).json({ error: err.message });
}
}
// POST /api/stream/session/stop
export async function stopSession(req: Request, res: Response) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
const user = await getUserFromToken(token);
if (!user) return res.status(401).json({ error: "Invalid token" });
const { data: session } = await supabaseAdmin
.from("stream_sessions")
.select("id, pod_id")
.eq("user_id", user.id)
.in("status", ["active", "starting"])
.maybeSingle();
if (!session) return res.json({ ok: true, message: "No active session" });
await cancelJob(session.pod_id);
await supabaseAdmin
.from("stream_sessions")
.update({ status: "stopped", ended_at: new Date().toISOString() })
.eq("id", session.id);
res.json({ ok: true });
}
// POST /api/stream/session/cpu — VP8 job for free users (same endpoint, FORCE_VP8=1)
export async function startCpuSession(req: Request, res: Response) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
const user = await getUserFromToken(token);
if (!user) return res.status(401).json({ error: "Invalid token" });
const { data: existing } = await supabaseAdmin
.from("stream_sessions")
.select("id, pod_id")
.eq("user_id", user.id)
.in("status", ["active", "starting"])
.maybeSingle();
if (existing) return res.json({ sessionId: existing.id, jobId: existing.pod_id, alreadyActive: true });
try {
const job = await spawnJob({
MODE: "game",
SIGNALING_URL,
TURN_SERVER,
AETHEX_USER_ID: user.id,
FORCE_VP8: "1",
IDLE_TIMEOUT: "600",
MAX_SESSION: "7200",
});
console.log(`[stream/cpu] serverless job spawned: ${job.id}`);
const { data: session } = await supabaseAdmin
.from("stream_sessions")
.insert({ user_id: user.id, pod_id: job.id, mode: "game", status: "starting" })
.select("id").single();
res.json({ sessionId: session!.id, jobId: job.id, status: "starting" });
} catch (err: any) {
console.error("[stream/cpu] error:", err.message);
res.status(500).json({ error: err.message });
}
}
// GET /api/stream/online (public — no auth)
export async function onlineCount(_req: Request, res: Response) {
const { count } = await supabaseAdmin
.from("stream_sessions")
.select("*", { count: "exact", head: true })
.in("status", ["active", "starting"]);
res.json({ count: count ?? 0 });
}
// GET /api/stream/session/status
export async function sessionStatus(req: Request, res: Response) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
const user = await getUserFromToken(token);
if (!user) return res.status(401).json({ error: "Invalid token" });
const { data: session } = await supabaseAdmin
.from("stream_sessions")
.select("id, pod_id, mode, game, status, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
res.json({ session: session || null });
}