const express = require('express'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const { createProxyMiddleware } = require('http-proxy-middleware'); const app = express(); const PORT = Number(process.env.PORT) || 4000; const ARCHIVES_DIR = path.join(__dirname, '..', 'archives'); const gameProcs = []; const tunnelProcs = []; const tunnelUrls = {}; const gamePorts = {}; const useHosting = process.argv.includes('--host') || process.env.HOSTING === 'true' || process.env.HOST === 'true'; function loadEntries() { const entries = []; const dirs = fs.readdirSync(ARCHIVES_DIR, { withFileTypes: true }); for (const d of dirs) { if (!d.isDirectory()) continue; const metaPath = path.join(ARCHIVES_DIR, d.name, 'archive.json'); if (!fs.existsSync(metaPath)) continue; try { const entry = JSON.parse(fs.readFileSync(metaPath, 'utf8')); entries.push(entry); } catch (e) { console.error(`Skipping ${d.name}: ${e.message}`); } } entries.sort((a, b) => new Date(b.date) - new Date(a.date)); return entries; } function getEntry(id) { return loadEntries().find(e => e.id === id); } function mapEntryUrl(entry) { const port = gamePorts[entry.id] || entry.port; if (port) { if (useHosting && tunnelUrls[port]) { return { ...entry, url: tunnelUrls[port] }; } return { ...entry, url: `/play/${entry.id}` }; } return entry; } function startTunnel(port) { return new Promise((resolve, reject) => { console.log(`[tunnel] Starting cloudflared tunnel for port ${port}...`); const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`]); let resolved = false; tunnelProcs.push(proc); const timer = setTimeout(() => { if (!resolved) { resolved = true; reject(new Error(`cloudflared tunnel for port ${port} timed out after 15 seconds`)); } }, 15000); const handleData = (data) => { const text = data.toString(); const match = text.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/); if (match && !resolved) { resolved = true; clearTimeout(timer); const url = match[0]; console.log(`[tunnel] Tunnel established for port ${port} -> ${url}`); resolve(url); } }; proc.stdout.on('data', handleData); proc.stderr.on('data', handleData); proc.on('error', (err) => { if (!resolved) { resolved = true; clearTimeout(timer); reject(new Error(`Failed to start cloudflared: ${err.message}`)); } }); proc.on('exit', (code) => { if (!resolved) { resolved = true; clearTimeout(timer); reject(new Error(`cloudflared exited with code ${code} before establishing tunnel`)); } }); }); } const refererProxies = {}; function getRefererProxy(port) { if (!refererProxies[port]) { refererProxies[port] = createProxyMiddleware({ target: `http://localhost:${port}`, changeOrigin: true, logLevel: 'silent', }); } return refererProxies[port]; } // 1. Redirect /play/:id to /play/:id/ to ensure proper relative asset resolution and set active_game cookie app.use((req, res, next) => { const match = req.path.match(/^\/play\/([^\/]+)/); if (match) { const gameId = match[1]; const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https'; res.cookie('active_game', gameId, { path: '/', sameSite: isSecure ? 'none' : 'lax', secure: isSecure }); if (req.path === `/play/${gameId}`) { return res.redirect(301, `${req.path}/`); } } next(); }); // Helper to extract game ID from referer or cookies function getGameId(req) { const referer = req.headers.referer || ''; // 1. Try to extract game ID directly from Referer (e.g. /play/rblx/...) const refMatch = referer.match(/\/play\/([^\/]+)/); if (refMatch) { return refMatch[1]; } // 2. Determine if the referer is the main portal page (path is exactly "/") let isPortal = false; if (referer) { try { const refUrl = new URL(referer); if (refUrl.pathname === '/') { isPortal = true; } } catch (e) { // Ignore URL parsing errors } } // 3. Fallback to cookie if we are not on the portal page if (!isPortal) { const cookieHeader = req.headers.cookie || ''; const cookieMatch = cookieHeader.match(/(?:^|; )active_game=([^;]*)/); if (cookieMatch) { return cookieMatch[1]; } } return null; } // 2. Referer/Cookie-based HTTP proxy for absolute paths (e.g. /css/..., /js/..., /api/...) app.use((req, res, next) => { if (req.path === '/ws') return next(); if (req.path.startsWith('/api/entries') || req.path.startsWith('/archives') || req.path.startsWith('/play/')) { return next(); } const gameId = getGameId(req); if (gameId) { const port = gamePorts[gameId]; if (port) { const proxy = getRefererProxy(port); return proxy(req, res, next); } } next(); }); // 3. WS Proxy specifically for WebSocket connections to /ws const wsProxy = createProxyMiddleware({ target: 'http://localhost:3000', // dummy fallback, overridden by router router: (req) => { const gameId = getGameId(req); if (gameId) { const port = gamePorts[gameId]; if (port) { return `http://localhost:${port}`; } } return undefined; }, changeOrigin: true, ws: true, logLevel: 'silent', on: { error: (err, req, res) => { console.error('[ws-proxy] error:', err); } } }); app.use('/ws', wsProxy); app.use('/archives', express.static(ARCHIVES_DIR)); app.use(express.static(path.join(__dirname, '..', 'public'))); app.get('/api/entries', (req, res) => { res.json(loadEntries().map(mapEntryUrl)); }); app.get('/api/entries/:id', (req, res) => { const entry = getEntry(req.params.id); if (!entry) return res.status(404).json({ error: 'not found' }); res.json(mapEntryUrl(entry)); }); function serveIndex(req, res) { res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); } async function startGameServers() { const entries = loadEntries(); let nextPort = 3001; const tunnelPromises = []; for (const entry of entries) { const gameDir = path.join(ARCHIVES_DIR, entry.id, 'app'); const serverEntry = path.join(gameDir, 'server', 'index.js'); if (!fs.existsSync(serverEntry)) continue; const port = entry.port || nextPort; nextPort = Math.max(nextPort, port) + 1; gamePorts[entry.id] = port; const proc = spawn('node', ['server/index.js'], { cwd: gameDir, stdio: 'pipe', env: { ...process.env, PORT: String(port) }, }); proc.stdout.on('data', d => process.stdout.write(`[${entry.id}] ${d}`)); proc.stderr.on('data', d => process.stderr.write(`[${entry.id}] ${d}`)); proc.on('error', err => console.error(`[${entry.id}] failed:`, err.message)); proc.on('exit', (code) => { if (code !== 0) console.error(`[${entry.id}] exited with code ${code}`); }); gameProcs.push(proc); app.use(`/play/${entry.id}`, createProxyMiddleware({ target: `http://localhost:${port}`, changeOrigin: true, })); console.log(`[proxy] /play/${entry.id} -> http://localhost:${port}`); if (useHosting) { const tunnelPromise = startTunnel(port) .then(url => { tunnelUrls[port] = url; }) .catch(err => { console.error(`[${entry.id}] Tunnel error: ${err.message}`); }); tunnelPromises.push(tunnelPromise); } } if (useHosting && tunnelPromises.length > 0) { console.log('[tunnel] Waiting for all game tunnels to be established...'); await Promise.all(tunnelPromises); console.log('[tunnel] All game tunnels established!'); } } function cleanup() { for (const proc of gameProcs) proc.kill(); for (const proc of tunnelProcs) proc.kill(); } function listen(port) { const srv = app.listen(port); srv.on('upgrade', wsProxy.upgrade); srv.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.log(`port ${port} busy, trying ${port + 1}`); listen(port + 1); } else { throw err; } }); srv.on('listening', async () => { const actualPort = srv.address().port; console.log(''); console.log(` ✦ AI Archives`); console.log(` ✦ running at http://localhost:${actualPort}`); if (useHosting) { try { const portalUrl = await startTunnel(actualPort); tunnelUrls[actualPort] = portalUrl; console.log(''); console.log(` ✦ Public Portal URL: ${portalUrl}`); console.log(''); } catch (err) { console.error(` ✦ Failed to start portal tunnel: ${err.message}`); console.log(''); } } else { console.log(''); } await startGameServers(); app.use(serveIndex); }); } process.on('SIGINT', () => { cleanup(); process.exit(); }); process.on('SIGTERM', () => { cleanup(); process.exit(); }); process.on('exit', cleanup); listen(PORT);