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:
parent
1a4321a531
commit
2ae331f9fe
6 changed files with 445 additions and 6 deletions
|
|
@ -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
200
api/stream/session.ts
Normal 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 });
|
||||
}
|
||||
204
client/pages/StreamUpgrade.tsx
Normal file
204
client/pages/StreamUpgrade.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,4 +11,4 @@ services:
|
|||
ports:
|
||||
- "5050:5000"
|
||||
env_file: .env
|
||||
command: npm run dev
|
||||
command: npm run start
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
19
supabase/migrations/20260418_add_stream_sessions.sql
Normal file
19
supabase/migrations/20260418_add_stream_sessions.sql
Normal 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);
|
||||
Loading…
Reference in a new issue