AIArchives / server /index.js
CompactAI's picture
Upload 126 files
b1376cc verified
Raw
History Blame Contribute Delete
9.14 kB
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);