| import express from 'express'; |
| import { createServer as createViteServer } from 'vite'; |
| import { createProxyMiddleware } from 'http-proxy-middleware'; |
| import path from 'path'; |
| import crypto from 'crypto'; |
| import { createRequire } from 'module'; |
| import { fileURLToPath } from 'url'; |
| import { spawn, execSync } from 'child_process'; |
| import fs from 'fs'; |
|
|
| const __filename = fileURLToPath(import.meta.url); |
| const __dirname = path.dirname(__filename); |
|
|
| const require = createRequire(import.meta.url); |
| const NodeMediaServer = require('node-media-server'); |
|
|
| let constTunnelUrl: string | null = null; |
| let startingBore = false; |
|
|
| async function startBoreTunnel() { |
| if (startingBore) return; |
| startingBore = true; |
| |
| const borePath = path.join(process.cwd(), 'bore'); |
| if (!fs.existsSync(borePath)) { |
| console.log('[SYSTEM] Downloading bore TCP proxy...'); |
| try { |
| const res = await fetch("https://github.com/ekzhang/bore/releases/download/v0.5.1/bore-v0.5.1-x86_64-unknown-linux-musl.tar.gz"); |
| const buffer = await res.arrayBuffer(); |
| fs.writeFileSync("bore.tar.gz", Buffer.from(buffer)); |
| execSync("tar -xzf bore.tar.gz"); |
| execSync("chmod +x bore"); |
| fs.unlinkSync("bore.tar.gz"); |
| } catch(e) { |
| console.error('[SYSTEM] Failed to download bore tunnel:', e); |
| return; |
| } |
| } |
|
|
| console.log('[SYSTEM] Starting bore TCP tunnel on port 1935...'); |
| const cp = spawn(borePath, ['local', '1935', '--to', 'bore.pub']); |
| |
| const handleData = (data: Buffer) => { |
| const text = data.toString(); |
| const match = text.match(/listening at (bore\.pub:\d+)/); |
| if (match) { |
| constTunnelUrl = `rtmp://${match[1]}/live`; |
| console.log(`[SYSTEM] TCP Tunnel active at: ${constTunnelUrl}`); |
| } |
| }; |
|
|
| cp.stdout.on('data', handleData); |
| cp.stderr.on('data', handleData); |
| |
| cp.on('close', (code) => { |
| console.log(`[SYSTEM] Bore tunnel closed with code ${code}`); |
| constTunnelUrl = null; |
| setTimeout(() => { startingBore = false; startBoreTunnel(); }, 5000); |
| }); |
| } |
|
|
| async function startServer() { |
| const app = express(); |
| const PORT = process.env.PORT ? parseInt(process.env.PORT as string, 10) : 3000; |
|
|
| |
| app.use((req, res, next) => { |
| console.log(`[REQ] ${req.method} ${req.url}`); |
| next(); |
| }); |
|
|
| |
| const nmsConfig = { |
| rtmp: { |
| port: 1935, |
| chunk_size: 60000, |
| gop_cache: true, |
| ping: 30, |
| ping_timeout: 60 |
| }, |
| http: { |
| port: 8123, |
| allow_origin: '*', |
| } |
| }; |
|
|
| const nms = new NodeMediaServer(nmsConfig); |
| nms.run(); |
|
|
| const activeStreams = new Set<string>(); |
| nms.on('prePublish', (...args: any[]) => { |
| const session = args[0]; |
| let streamPath = (typeof session === 'string') ? args[1] : (session?.streamPath || session?.StreamPath || session?.publishStreamPath); |
| if (args.length > 1 && typeof args[1] === 'string') { |
| streamPath = args[1]; |
| } |
| console.log('[SYSTEM] RTMP stream prePublish payload:', streamPath); |
| |
| if (streamPath) activeStreams.add(streamPath.toLowerCase()); |
| }); |
| nms.on('postPublish', (...args: any[]) => { |
| const session = args[0]; |
| let streamPath = (typeof session === 'string') ? args[1] : (session?.streamPath || session?.StreamPath || session?.publishStreamPath); |
| if (args.length > 1 && typeof args[1] === 'string') { |
| streamPath = args[1]; |
| } |
| console.log('[SYSTEM] RTMP stream started:', streamPath); |
| if (streamPath) activeStreams.add(streamPath.toLowerCase()); |
| }); |
| nms.on('donePublish', (...args: any[]) => { |
| const session = args[0]; |
| let streamPath = (typeof session === 'string') ? args[1] : (session?.streamPath || session?.StreamPath || session?.publishStreamPath); |
| if (args.length > 1 && typeof args[1] === 'string') { |
| streamPath = args[1]; |
| } |
| console.log('[SYSTEM] RTMP stream ended:', streamPath); |
| if (streamPath) activeStreams.delete(streamPath.toLowerCase()); |
| }); |
|
|
| |
| startBoreTunnel(); |
|
|
| |
| |
| app.use('/live', createProxyMiddleware({ |
| target: 'http://127.0.0.1:8123', |
| changeOrigin: true, |
| ws: true |
| })); |
|
|
| |
| app.get('/api/streams/:app/:key', (req, res) => { |
| const streamPath = `/${req.params.app}/${req.params.key}`.toLowerCase(); |
| const isActive = activeStreams.has(streamPath); |
| console.log(`[REQ] GET /api/streams${streamPath} => ${isActive}`); |
| res.json({ active: isActive }); |
| }); |
|
|
| |
| app.get('/api/config', async (req, res) => { |
| let rtmpUrl = ''; |
| |
| |
| if (constTunnelUrl) { |
| return res.json({ rtmpUrl: constTunnelUrl, isNgrok: true }); |
| } |
|
|
| |
| if (process.env.SPACE_HOST) { |
| rtmpUrl = `rtmp://${process.env.SPACE_HOST}:1935/live`; |
| } else { |
| const appUrl = process.env.APP_URL || 'localhost'; |
| |
| if (appUrl.startsWith('https://')) { |
| const hostname = new URL(appUrl).hostname; |
| rtmpUrl = `rtmp://${hostname}:1935/live`; |
| } else { |
| rtmpUrl = `rtmp://${appUrl.split(':')[0]}:1935/live`; |
| } |
| } |
|
|
| res.json({ rtmpUrl }); |
| }); |
|
|
| |
| if (process.env.NODE_ENV !== 'production') { |
| const vite = await createViteServer({ |
| server: { middlewareMode: true }, |
| appType: 'spa', |
| }); |
| app.use(vite.middlewares); |
| } else { |
| |
| |
| const distPath = __dirname; |
| console.log('[SYSTEM] Static dist path:', distPath); |
| |
| app.use('/assets', express.static(path.join(distPath, 'assets'), { |
| setHeaders: (res, filePath) => { |
| res.setHeader('Access-Control-Allow-Origin', '*'); |
| res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); |
| res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); |
| } |
| })); |
|
|
| |
| app.use(express.static(distPath, { |
| setHeaders: (res, filePath) => { |
| res.setHeader('Access-Control-Allow-Origin', '*'); |
| res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); |
| if (filePath.endsWith('.html')) { |
| res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); |
| res.setHeader('Pragma', 'no-cache'); |
| res.setHeader('Expires', '0'); |
| } |
| } |
| })); |
| |
| |
| app.get('/api/health', (req, res) => { |
| res.json({ status: 'ok', dir: __dirname, time: new Date().toISOString() }); |
| }); |
|
|
| app.get('/assets/*', (req, res) => { |
| res.status(404).send('Not Found'); |
| }); |
|
|
| app.get('*', (req, res) => { |
| res.setHeader('Access-Control-Allow-Origin', '*'); |
| res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); |
| res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); |
| res.setHeader('Pragma', 'no-cache'); |
| res.setHeader('Expires', '0'); |
| res.sendFile(path.join(distPath, 'index.html')); |
| }); |
| } |
|
|
| const server = app.listen(PORT, '0.0.0.0', () => { |
| console.log(`Express server running on http://localhost:${PORT}`); |
| }); |
|
|
| |
| process.on('SIGTERM', () => { |
| console.log('SIGTERM received, shutting down...'); |
| nms.stop(); |
| server.close(); |
| process.exit(0); |
| }); |
| } |
|
|
| startServer(); |
|
|