Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ FROM node:20-alpine AS production
# Set environment
ENV NODE_ENV=production
ENV PORT=3000
ENV NODE_OPTIONS="--max-old-space-size=2048 --expose-gc"

WORKDIR /app

Expand Down Expand Up @@ -73,5 +74,5 @@ EXPOSE 2237/udp
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1

# Start server with explicit heap limit (fail fast rather than slow OOM at 4GB)
CMD ["node", "--max-old-space-size=1024", "server.js"]
# Start server with explicit heap limit and GC access for periodic compaction
CMD ["node", "--max-old-space-size=2048", "--expose-gc", "server.js"]
45 changes: 38 additions & 7 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,11 @@ function loadVisitorStats() {
totalRequestsToday: data.today === new Date().toISOString().slice(0, 10) ? (data.totalRequestsToday || 0) : 0,
allTimeVisitors: data.allTimeVisitors || 0,
allTimeRequests: data.allTimeRequests || 0,
allTimeUniqueIPs: data.allTimeUniqueIPs || [],
// Reconstruct from geoIPCache keys (covers ~99% of IPs) + any legacy array
allTimeUniqueIPs: [...new Set([
...(data.allTimeUniqueIPs || []),
...Object.keys(data.geoIPCache || {})
])],
serverFirstStarted: data.serverFirstStarted || defaults.serverFirstStarted,
lastDeployment: new Date().toISOString(),
deploymentCount: (data.deploymentCount || 0) + 1,
Expand Down Expand Up @@ -925,12 +929,16 @@ function saveVisitorStats() {
fs.mkdirSync(dir, { recursive: true });
}

// Don't persist allTimeUniqueIPs array — it grows forever and can be
// reconstructed from geoIPCache keys on restart. Save memory.
const data = {
...visitorStats,
allTimeUniqueIPs: undefined, // Exclude from JSON — reconstructed on load
lastSaved: new Date().toISOString()
};

fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
// Use compact JSON (no pretty-print) to avoid multi-MB temporary strings
fs.writeFileSync(STATS_FILE, JSON.stringify(data));
visitorStats.lastSaved = data.lastSaved; // Update in-memory too
saveErrorCount = 0; // Reset on success
// Only log occasionally to avoid spam
Expand All @@ -955,6 +963,10 @@ const visitorStats = loadVisitorStats();
// Convert today's IPs to a Set for fast lookup
const todayIPSet = new Set(visitorStats.uniqueIPsToday);
const allTimeIPSet = new Set(visitorStats.allTimeUniqueIPs);
const MAX_TRACKED_IPS = 100000; // Stop tracking individual IPs after this (just count)

// Free the array — Set is the authoritative source now, array is no longer persisted
visitorStats.allTimeUniqueIPs = [];

// ============================================
// GEO-IP COUNTRY RESOLUTION
Expand Down Expand Up @@ -998,8 +1010,11 @@ function queueGeoIPLookup(ip) {
*/
function recordCountry(ip, countryCode) {
if (!countryCode || countryCode === 'Unknown') return;
geoIPCache.set(ip, countryCode);
visitorStats.geoIPCache[ip] = countryCode;
// Only cache individual IP→country mappings up to the cap
if (geoIPCache.size < MAX_TRACKED_IPS || geoIPCache.has(ip)) {
geoIPCache.set(ip, countryCode);
visitorStats.geoIPCache[ip] = countryCode;
}

// All-time stats
visitorStats.countryStats[countryCode] = (visitorStats.countryStats[countryCode] || 0) + 1;
Expand Down Expand Up @@ -1332,8 +1347,10 @@ app.use((req, res, next) => {
// Track all-time unique visitors
const isNewAllTime = !allTimeIPSet.has(ip);
if (isNewAllTime) {
allTimeIPSet.add(ip);
visitorStats.allTimeUniqueIPs.push(ip);
// Only track individual IPs up to the cap (prevents unbounded memory growth)
if (allTimeIPSet.size < MAX_TRACKED_IPS) {
allTimeIPSet.add(ip);
}
visitorStats.allTimeVisitors++;
queueGeoIPLookup(ip);
logInfo(`[Stats] New visitor (#${visitorStats.uniqueIPsToday.length} today, #${visitorStats.allTimeVisitors} all-time) from ${ip.replace(/\d+$/, 'x')}`);
Expand Down Expand Up @@ -1370,9 +1387,23 @@ setInterval(() => {
spotBufferEntries: pskMqtt.spotBuffer.size,
spotBufferTotal: [...pskMqtt.spotBuffer.values()].reduce((n, b) => n + b.length, 0),
};
console.log(`[Memory] RSS=${mb(mem.rss)}MB Heap=${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB External=${mb(mem.external)}MB | MQTT: ${mqttStats.sseClients} SSE clients, ${mqttStats.subscribedCalls} calls, ${mqttStats.recentSpotsTotal} recent spots (${mqttStats.recentSpotsEntries} entries), ${mqttStats.spotBufferTotal} buffered | GeoIP=${geoIPCache.size} CallLookup=${callsignLookupCache?.size || 0} MySpots=${mySpotsCache.size} AllTimeIPs=${allTimeIPSet.size}`);
console.log(`[Memory] RSS=${mb(mem.rss)}MB Heap=${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB External=${mb(mem.external)}MB | MQTT: ${mqttStats.sseClients} SSE clients, ${mqttStats.subscribedCalls} calls, ${mqttStats.recentSpotsTotal} recent spots (${mqttStats.recentSpotsEntries} entries), ${mqttStats.spotBufferTotal} buffered | GeoIP=${geoIPCache.size} CallLookup=${callsignLookupCache?.size || 0} MySpots=${mySpotsCache.size} AllTimeIPs=${allTimeIPSet.size} RBN=${rbnSpotsByDX?.size || 0}`);
}, 15 * 60 * 1000);

// Periodic GC compaction — helps V8 release fragmented old-space memory
// Without this, long-running processes slowly accumulate unreclaimable heap
setInterval(() => {
if (typeof global.gc === 'function') {
const before = process.memoryUsage().heapUsed;
global.gc();
const after = process.memoryUsage().heapUsed;
const freed = ((before - after) / 1024 / 1024).toFixed(1);
if (freed > 5) {
console.log(`[GC] Compaction freed ${freed}MB (${(before / 1024 / 1024).toFixed(0)}MB → ${(after / 1024 / 1024).toFixed(0)}MB)`);
}
}
}, 30 * 60 * 1000); // Every 30 minutes

// ============================================
// AUTO UPDATE (GIT)
// ============================================
Expand Down