/** * Red Valley — NUI Simulator Server * Scans a FiveM server folder for NUI resources and serves them for browser testing. * * Usage: node server.js [path-to-server-folder] * Default path: parent directory (../) */ const express = require('express'); const path = require('path'); const fs = require('fs'); const { exec } = require('child_process'); // ============ CONFIG ============ const PORT = 3200; const args = process.argv.slice(2); const SERVER_PATH = args.find(a => !a.startsWith('--')) || path.resolve(__dirname, '..'); const RESOURCES_PATH = path.join(SERVER_PATH, 'resources'); const AUTO_OPEN = args.includes('--open'); // ============ NUI SCANNER ============ /** * Recursively find all fxmanifest.lua / __resource.lua files */ function findManifests(dir, results = []) { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Skip node_modules, .git, stream folders if (['node_modules', '.git', 'stream', 'streams'].includes(entry.name)) continue; findManifests(fullPath, results); } else if (entry.name === 'fxmanifest.lua' || entry.name === '__resource.lua') { results.push(fullPath); } } } catch (e) { // Permission denied or similar - skip } return results; } /** * Parse a fxmanifest.lua to extract ui_page directive */ function parseManifest(manifestPath) { try { const content = fs.readFileSync(manifestPath, 'utf-8'); // Match ui_page 'path' or ui_page "path" or ui_page('path') etc. const patterns = [ /ui_page\s*[\(\{]?\s*['"]([^'"]+)['"]\s*[\)\}]?/i, /ui_page\s+['"]([^'"]+)['"]/i, ]; for (const pattern of patterns) { const match = content.match(pattern); if (match) { return match[1]; } } } catch (e) { // Can't read file } return null; } /** * Extract resource name from path */ function getResourceName(manifestPath) { const dir = path.dirname(manifestPath); return path.basename(dir); } /** * Get category/tags based on folder structure */ function categorize(relativePath) { const rp = relativePath.toLowerCase(); if (rp.includes('[core]')) return { tag: 'core', color: '#c084fc' }; if (rp.includes('[depends]')) return { tag: 'depends', color: '#60a5fa' }; if (rp.includes('[addons]')) return { tag: 'addon', color: '#f59e0b' }; if (rp.includes('[jobs]')) return { tag: 'job', color: '#22c55e' }; if (rp.includes('[auth]')) return { tag: 'auth', color: '#f472b6' }; if (rp.includes('[ui]')) return { tag: 'ui', color: '#38bdf8' }; if (rp.includes('[legal]')) return { tag: 'legal', color: '#34d399' }; return { tag: 'other', color: '#8b8d94' }; } /** * Auto-detect icon based on resource name */ function getIcon(name) { const icons = { 'phone': '📱', 'radio': '📻', 'hud': '📊', 'menu': '📋', 'target': '🎯', 'input': '📝', 'notify': '🔔', 'sound': '🔊', 'progress': '⏳', 'banking': '🏦', 'casino': '🎰', 'dice': '🎲', 'admin': '⚙️', 'ambulance': '🚑', 'police': '🚔', 'mechanic': '🔧', 'dispatch': '📻', 'boss': '👔', 'character': '👤', 'license': '🔒', 'job': '💼', 'garbage': '🗑️', 'lumber': '🪓', 'miner': '⛏️', 'oil': '🛢️', 'bus': '🚌', 'car': '🚗', 'tuning': '🏎️', 'driver': '🏫', 'anim': '💃', 'dyno': '📈', 'smart': '📱', }; const nameLower = name.toLowerCase(); for (const [key, icon] of Object.entries(icons)) { if (nameLower.includes(key)) return icon; } return '📦'; } /** * Scan all resources and return NUI info */ function scanNUIs() { console.log(`\n 🔍 Scanning: ${RESOURCES_PATH}\n`); if (!fs.existsSync(RESOURCES_PATH)) { console.error(` ❌ Resources folder not found: ${RESOURCES_PATH}`); process.exit(1); } const manifests = findManifests(RESOURCES_PATH); console.log(` 📄 Found ${manifests.length} manifest files`); const nuis = []; for (const mf of manifests) { const uiPage = parseManifest(mf); if (uiPage) { const resourceDir = path.dirname(mf); const name = getResourceName(mf); const relativePath = path.relative(RESOURCES_PATH, resourceDir).replace(/\\/g, '/'); const uiFullPath = path.join(resourceDir, uiPage); const category = categorize(relativePath); // Check if the UI file actually exists const exists = fs.existsSync(uiFullPath); nuis.push({ name, path: relativePath, uiPage, uiFullPath: path.relative(RESOURCES_PATH, uiFullPath).replace(/\\/g, '/'), exists, icon: getIcon(name), ...category, }); } } // Sort: existing first, then alphabetically nuis.sort((a, b) => { if (a.exists !== b.exists) return b.exists - a.exists; return a.name.localeCompare(b.name); }); console.log(` 🎨 Found ${nuis.length} NUI resources\n`); nuis.forEach(n => { const status = n.exists ? '✅' : '⚠️'; console.log(` ${status} ${n.icon} ${n.name} → ${n.uiPage}`); }); console.log(''); return nuis; } // ============ SERVER ============ const app = express(); // Scan NUIs at startup const discoveredNUIs = scanNUIs(); // API: return discovered NUIs app.get('/api/nuis', (req, res) => { res.json(discoveredNUIs); }); // API: rescan app.get('/api/rescan', (req, res) => { const fresh = scanNUIs(); discoveredNUIs.length = 0; discoveredNUIs.push(...fresh); res.json(fresh); }); // Serve NUI files from resources folder app.use('/resources', express.static(RESOURCES_PATH)); // Serve mock files from docs folder app.use('/mocks', express.static(path.join(SERVER_PATH, 'docs'))); // Serve the simulator UI app.use(express.static(path.join(__dirname, 'public'))); // Start app.listen(PORT, () => { const url = `http://localhost:${PORT}`; console.log(` ╔═══════════════════════════════════════════════════╗`); console.log(` ║ ║`); console.log(` ║ 🎮 Red Valley — NUI Simulator ║`); console.log(` ║ ║`); console.log(` ║ Server: ${url} ║`); console.log(` ║ Resources: ${discoveredNUIs.length} NUIs discovered ║`); console.log(` ║ ║`); console.log(` ╚═══════════════════════════════════════════════════╝\n`); // Auto-open browser if (AUTO_OPEN) { const cmd = process.platform === 'win32' ? 'start' : 'open'; exec(`${cmd} ${url}`); } });