Server Implementation
Overview
TVx uses a custom Node.js server (server.js) instead of nginx to serve the application and handle logging. This document explains the implementation details, rationale, and key features.
Architecture
1
2
3
4
5
6
7
8
9
Docker Container
├── Node.js (Alpine)
├── /usr/share/nginx/html/ (static files)
│ ├── index.html
│ ├── assets/
│ ├── loading-VHS.mp4
│ ├── env.js (generated at runtime)
│ └── server.js
└── Port 80
Why Node.js Instead of Nginx?
The switch from nginx to Node.js was made to support:
- Server-side logging: Capture client-side logs in Docker container logs
- Dynamic configuration: Generate
env.jsat runtime using environment variables - Custom endpoints: Add
/logendpoint for centralized logging - Simpler deployment: Single runtime (Node.js) instead of nginx + separate tools
Trade-offs
Advantages:
- ✅ Custom logging endpoint
- ✅ Dynamic environment configuration
- ✅ Simpler Docker image (single runtime)
- ✅ Easy to extend with custom routes
Disadvantages:
- ⚠️ Less battle-tested than nginx for static file serving
- ⚠️ Requires implementing features nginx provides for free (range requests, compression, caching)
- ⚠️ Potentially lower performance under high load
Server.js Implementation
Core Functionality
The server handles three main responsibilities:
- Static file serving - Serves React build artifacts
- Logging endpoint - Receives logs from browser
- SPA routing - Falls back to
index.htmlfor React Router
Code Structure
1
2
3
4
5
6
7
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const PORT = 80;
const STATIC_DIR = '/usr/share/nginx/html';
Key Features
1. Logging Endpoint
Route: POST /log
Receives logs from the browser and outputs them to Docker logs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (req.method === 'POST' && pathname === '/log') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const logData = JSON.parse(body);
const timestamp = new Date().toISOString();
const level = logData.level || 'info';
const message = logData.message || body;
console.log(`[${timestamp}] ${level.toUpperCase()}: ${message}`);
} catch (e) {
console.log(`[${new Date().toISOString()}] LOG: ${body}`);
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
});
return;
}
Usage from client:
1
2
3
4
5
6
7
8
// src/utils/logger.ts
const sendLog = async (level: string, message: string) => {
await fetch('/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level, message }),
});
};
2. HTTP Range Request Support
Critical for video playback! Browsers require range request support for video elements.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Handle range requests for video files
if (ext === '.mp4' && req.headers.range) {
const range = req.headers.range;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
const chunksize = (end - start) + 1;
const head = {
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': contentType,
};
res.writeHead(206, head);
fs.createReadStream(filePath, { start, end }).pipe(res);
}
Why this is essential:
- Browsers use range requests to seek in videos
- Without this, video elements fail with
NotSupportedError - Nginx supports this automatically
- See bug fix:
docs/bugfix/2025-10/vhs-video-not-playing.md
3. MIME Type Handling
1
2
3
4
5
6
7
8
9
10
11
const contentType = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webmanifest': 'application/manifest+json'
}[ext] || 'text/plain';
4. SPA Fallback
Falls back to index.html for React Router routes:
1
2
3
4
5
6
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
filePath = path.join(STATIC_DIR, 'index.html');
}
// ... serve file
});
Environment Configuration
Runtime Generation
Environment variables are injected at container startup:
1
CMD sh -c "envsubst < /usr/share/nginx/html/env.js.template > /usr/share/nginx/html/env.js && node /usr/share/nginx/html/server.js"
Template (env.js.template)
1
2
3
4
window.ENV = {
VITE_M3U_URL: "$VITE_M3U_URL",
VITE_XMLTV_URL: "$VITE_XMLTV_URL"
};
Generated (env.js)
1
2
3
4
window.ENV = {
VITE_M3U_URL: "http://192.168.22.2:8000/api/channels.m3u",
VITE_XMLTV_URL: "http://192.168.22.2:8000/api/xmltv.xml"
};
Loading in Browser
index.html includes:
1
<script src="/env.js"></script>
Client code accesses via:
1
const m3uUrl = (window as any).ENV?.VITE_M3U_URL || 'default-url';
Logging System
Architecture
1
2
3
4
5
6
7
Browser (React App)
↓ fetch('/log', ...)
Node.js Server (/log endpoint)
↓ console.log(...)
Docker Container Logs
↓ docker logs tvx
User/Admin
Client-Side Logger
Location: src/utils/logger.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export const logger = {
log: (message: string) => {
console.log(message);
sendLog('info', message);
},
error: (message: string) => {
console.error(message);
sendLog('error', message);
},
warn: (message: string) => {
console.warn(message);
sendLog('warn', message);
},
info: (message: string) => {
console.info(message);
sendLog('info', message);
},
};
Usage Examples
1
2
3
4
5
6
7
8
9
10
11
// Channel loading
logger.log(`Loaded ${channels.length} channels`);
// Channel switching
logger.info(`Channel changed to: ${channel.name}`);
// Errors
logger.error(`Failed to parse XMLTV: ${error}`);
// Stream URLs
logger.log(`Loaded stream URL for ${channel.name}: ${channel.url}`);
Log Format
1
[ISO-8601-TIMESTAMP] LEVEL: MESSAGE
Example:
1
2
3
[2025-10-15T20:18:28.467Z] INFO: Loaded 20 channels
[2025-10-15T20:18:28.481Z] INFO: Channel changed to: Favourite TV Shows
[2025-10-15T20:18:28.529Z] INFO: Loaded EPG data for 467 programmes
Performance Considerations
Current Implementation
- ✅ Simple and easy to understand
- ✅ Sufficient for typical TVx usage (personal/family use)
- ⚠️ Not optimized for high traffic
Missing Features (vs Nginx)
- No gzip compression - Nginx compresses responses automatically
- No caching headers - Nginx sets cache headers for static assets
- No connection pooling - Nginx handles this efficiently
- No rate limiting - Nginx can limit request rates
- No HTTPS - Typically handled by reverse proxy
Recommended Production Setup
For production deployments, consider:
- Use nginx as reverse proxy:
1 2 3 4 5 6 7 8
server { listen 443 ssl; location / { proxy_pass http://localhost:8777; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }
- Or add compression to server.js:
1 2
const zlib = require('zlib'); // Compress responses
- Or switch back to nginx + separate logging service
Testing
Test Static File Serving
1
curl http://localhost:8777/
Should return the React app HTML.
Test Logging Endpoint
1
2
3
curl -X POST http://localhost:8777/log \
-H "Content-Type: application/json" \
-d '{"level":"info","message":"Test log"}'
Should return OK and appear in Docker logs.
Test Range Requests
1
curl -I -H "Range: bytes=0-1023" http://localhost:8777/loading-VHS.mp4
Should return:
1
2
3
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/9518187
Accept-Ranges: bytes
View Logs
1
docker logs -f tvx
Troubleshooting
Video Not Playing
Symptom: Video elements show NotSupportedError
Solution: Ensure range request support is implemented (see above)
Debug:
1
curl -I -H "Range: bytes=0-1023" http://localhost:8777/loading-VHS.mp4
Should return 206 Partial Content, not 200 OK.
Logs Not Appearing
Symptom: Browser logs don’t appear in Docker logs
Debug:
1
2
3
4
5
# Test logging endpoint
curl -X POST http://localhost:8777/log -d '{"level":"test","message":"test"}'
# Check if it appears
docker logs tvx | tail -1
Environment Variables Not Loading
Symptom: App uses default URLs instead of environment variables
Debug:
1
2
3
4
# Check if env.js was generated
docker exec tvx cat /usr/share/nginx/html/env.js
# Should show your URLs, not $VITE_M3U_URL
Future Improvements
Potential Enhancements
- Add gzip compression for better performance
- Implement caching headers for static assets
- Add health check endpoint (
/health) - Add metrics endpoint for monitoring
- Implement log levels (debug, info, warn, error)
- Add log rotation to prevent disk fill
- WebSocket support for real-time updates
- API endpoints for settings management
Alternative Approaches
- Keep nginx, add logging service:
- Use nginx for static files (faster, more features)
- Add separate Node.js service for logging
- More complex architecture but better performance
- Use Express.js:
- More mature server framework
- Built-in middleware for compression, caching, etc.
- Easier to extend
- Use existing static server packages:
serve-statichttp-server- Battle-tested, includes range requests, etc.
References
- Bug Fix: Range Request Support -
docs/bugfix/2025-10/vhs-video-not-playing.md - HTTP Range Requests (RFC 7233): https://tools.ietf.org/html/rfc7233
- Node.js HTTP Server: https://nodejs.org/api/http.html
- Docker Environment Variables: https://docs.docker.com/engine/reference/run/#env-environment-variables
Related Files
server.js- The server implementationenv.js.template- Environment variable templateDockerfile- Container build configurationsrc/utils/logger.ts- Client-side loggerdocs/bugfix/2025-10/vhs-video-not-playing.md- Range request bug fix