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>
This commit is contained in:
AeThex 2026-04-19 16:42:44 +00:00
parent 1a4321a531
commit 2ae331f9fe
6 changed files with 445 additions and 6 deletions

View file

@ -21,8 +21,8 @@ ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_AUTHENTIK_PROVIDER=$VITE_AUTHENTIK_PROVIDER
# Build the client so the Activity gets compiled JS (no Vite dev mode in Discord iframe)
RUN npm run build:client
# Build everything
RUN npm run build
# Set environment
ENV NODE_ENV=production
@ -31,5 +31,4 @@ ENV PORT=3000
# Expose port
EXPOSE 3000
# Start the server
CMD ["npm", "run", "dev"]
CMD ["npm", "run", "start"]

200
api/stream/session.ts Normal file
View file

@ -0,0 +1,200 @@
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 });
}

View file

@ -0,0 +1,204 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Loader2, Zap, Users, Check } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/lib/supabase";
import { aethexToast } from "@/lib/aethex-toast";
import Layout from "@/components/Layout";
const PLANS = [
{
id: "stream_pro",
name: "Stream Pro",
price: "$9.99",
period: "/mo",
icon: Zap,
seats: 1,
resolution: "1080p60",
bitrate: "15 Mbps",
features: ["GPU-accelerated stream", "1080p60 quality", "1 guaranteed seat", "Priority over free viewers", "No ads"],
},
{
id: "stream_team",
name: "Stream Team",
price: "$24.99",
period: "/mo",
icon: Users,
seats: 4,
resolution: "1080p60",
bitrate: "15 Mbps",
features: ["GPU-accelerated stream", "1080p60 quality", "4 guaranteed seats", "Priority over free viewers", "No ads", "Team management"],
highlighted: true,
},
] as const;
function StreamSuccess() {
const [token, setToken] = useState("");
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.access_token) setToken(session.access_token);
});
}, []);
return (
<Layout>
<div style={{ minHeight: "80vh", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Courier New', monospace" }}>
<div style={{ textAlign: "center", maxWidth: 420, padding: "0 24px" }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<div style={{ fontFamily: "Orbitron, monospace", fontSize: 20, fontWeight: 900, color: "#00FF88", letterSpacing: 3, marginBottom: 8 }}>
SUBSCRIPTION ACTIVE
</div>
<p style={{ color: "#8EA8CC", fontSize: 13, lineHeight: 1.7, marginBottom: 24 }}>
Your GPU stream access is now live. Head back to aethex.live to start playing.
</p>
<a
href={`https://aethex.live?token=${token}`}
style={{ display: "inline-block", padding: "10px 28px", border: "1px solid #00D4FF", background: "rgba(0,212,255,0.12)", color: "#00D4FF", borderRadius: 2, letterSpacing: 2, fontSize: 12, textDecoration: "none" }}
>
LAUNCH STREAM
</a>
</div>
</div>
</Layout>
);
}
export default function StreamUpgrade() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const [loading, setLoading] = useState<string | null>(null);
const defaultPlan = searchParams.get("plan") === "stream_team" ? "stream_team" : "stream_pro";
const [selectedPlan, setSelectedPlan] = useState<string>(defaultPlan);
async function handleCheckout() {
if (!user) {
navigate("/login?redirect=/stream/upgrade?plan=" + selectedPlan);
return;
}
setLoading(selectedPlan);
try {
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
if (!token) throw new Error("Not authenticated");
const res = await fetch("/api/subscriptions/create-checkout", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
tier: selectedPlan,
successUrl: `${window.location.origin}/stream/upgrade?success=true&plan=${selectedPlan}`,
cancelUrl: `${window.location.origin}/stream/upgrade?plan=${selectedPlan}`,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Checkout failed");
// Pass token back to aethex.live after upgrade
if (data.url) {
window.location.href = data.url;
}
} catch (err: any) {
aethexToast.error(err.message || "Something went wrong");
} finally {
setLoading(null);
}
}
const success = searchParams.get("success") === "true";
if (success) {
return <StreamSuccess />;
}
return (
<Layout>
<div style={{ minHeight: "80vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "40px 24px", fontFamily: "'Courier New', monospace" }}>
<div style={{ textAlign: "center", marginBottom: 40 }}>
<div style={{ fontFamily: "Orbitron, monospace", fontSize: 22, fontWeight: 900, color: "#D8E8FF", letterSpacing: 4, marginBottom: 8 }}>
STREAM <span style={{ color: "#00D4FF" }}>ACCESS</span>
</div>
<p style={{ color: "#4E6280", fontSize: 13, maxWidth: 400, margin: "0 auto", lineHeight: 1.7 }}>
Upgrade for guaranteed GPU-accelerated seats on aethex.live. Free slots fill up fast.
</p>
</div>
<div style={{ display: "flex", gap: 16, marginBottom: 32, flexWrap: "wrap", justifyContent: "center" }}>
{PLANS.map((plan) => {
const Icon = plan.icon;
const isSelected = selectedPlan === plan.id;
return (
<div
key={plan.id}
onClick={() => setSelectedPlan(plan.id)}
style={{
width: 260,
border: `1px solid ${isSelected ? "#00D4FF" : plan.highlighted ? "#C9A84C" : "#161E38"}`,
borderRadius: 3,
padding: "24px 20px",
background: isSelected ? "rgba(0,212,255,0.06)" : "rgba(8,12,24,0.8)",
cursor: "pointer",
transition: "border-color .2s, background .2s",
position: "relative",
}}
>
{plan.highlighted && (
<div style={{ position: "absolute", top: -10, left: "50%", transform: "translateX(-50%)", background: "#C9A84C", color: "#04060E", fontSize: 9, padding: "3px 10px", borderRadius: 2, letterSpacing: 2, whiteSpace: "nowrap" }}>
BEST VALUE
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Icon size={16} color={isSelected ? "#00D4FF" : "#4E6280"} />
<span style={{ fontFamily: "Orbitron, monospace", fontSize: 12, fontWeight: 700, color: "#D8E8FF", letterSpacing: 2 }}>
{plan.name.toUpperCase()}
</span>
</div>
<div style={{ marginBottom: 16 }}>
<span style={{ fontSize: 28, fontWeight: 700, color: "#00D4FF" }}>{plan.price}</span>
<span style={{ fontSize: 12, color: "#4E6280" }}>{plan.period}</span>
</div>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{plan.features.map((f) => (
<li key={f} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: "#8EA8CC", marginBottom: 6 }}>
<Check size={11} color="#00FF88" />
{f}
</li>
))}
</ul>
</div>
);
})}
</div>
<button
onClick={handleCheckout}
disabled={!!loading}
style={{
fontFamily: "'Courier New', monospace",
fontSize: 12,
padding: "12px 40px",
border: "1px solid #00D4FF",
background: "rgba(0,212,255,0.12)",
color: "#00D4FF",
cursor: loading ? "not-allowed" : "pointer",
borderRadius: 2,
letterSpacing: 2,
display: "flex",
alignItems: "center",
gap: 8,
opacity: loading ? 0.7 : 1,
}}
>
{loading ? <Loader2 size={14} className="animate-spin" /> : null}
{loading ? "REDIRECTING..." : "SUBSCRIBE NOW"}
</button>
<p style={{ marginTop: 12, fontSize: 10, color: "#364860" }}>
Cancel anytime · Secured by Stripe
</p>
</div>
</Layout>
);
}

View file

@ -11,4 +11,4 @@ services:
ports:
- "5050:5000"
env_file: .env
command: npm run dev
command: npm run start

View file

@ -44,6 +44,7 @@ import aiChatHandler from "../api/ai/chat";
import aiTitleHandler from "../api/ai/title";
import createCheckoutHandler from "../api/subscriptions/create-checkout";
import manageSubscriptionHandler from "../api/subscriptions/manage";
import { startSession, stopSession, sessionStatus, onlineCount, startCpuSession } from "../api/stream/session";
// Developer API Keys handlers
import {
@ -335,7 +336,15 @@ export function createServer() {
);
// Middleware
app.use(cors());
app.use(cors({
origin: [
"https://aethex.live",
"https://aethex.dev",
"https://aethex.tech",
/^https?:\/\/localhost(:\d+)?$/,
],
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@ -8273,6 +8282,14 @@ export function createServer() {
app.post("/api/ai/chat", aiChatHandler);
app.post("/api/ai/title", aiTitleHandler);
// Stream session API
app.post("/api/stream/session/start", startSession);
app.post("/api/stream/session/stop", stopSession);
app.get("/api/stream/session/status", sessionStatus);
app.get("/api/stream/online", onlineCount);
app.post("/api/stream/session/cpu", startCpuSession);
// Subscription API routes
app.post("/api/subscriptions/create-checkout", (req: express.Request, res: express.Response) => {
return createCheckoutHandler(req as any, res as any);

View file

@ -0,0 +1,19 @@
create table if not exists stream_sessions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
pod_id text not null,
mode text not null default 'game',
game text,
status text not null default 'starting',
created_at timestamptz not null default now(),
ended_at timestamptz
);
alter table stream_sessions enable row level security;
create policy "Users see own sessions"
on stream_sessions for select
using (auth.uid() = user_id);
create index on stream_sessions(user_id, status);
create index on stream_sessions(created_at desc);