Spaces:
Running
Running
| 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); | |