mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-09 10:49:37 +00:00
feat: implement sprint 1 quick wins - toast system, notifications, UX polish
- Add central toast notification system (popup.html, popup.js) - Add browser notifications toggle (opt-in) with event toasts - Fix interpolation memory leak (unload listener) - Add /health endpoint with IP-based rate limiting (server) - Improve tab sorting (current tab first, matches, alphabetical) - Add copy-to-clipboard visual feedback with toast - Show targetTime in last action card for seek/force sync - Add explicit video cleanup when element removed (content.js) - Update ROADMAP.md to remove implemented features
This commit is contained in:
+4
-623
@@ -4,26 +4,14 @@ This document tracks planned features, improvements, and their implementation de
|
||||
|
||||
---
|
||||
|
||||
## Antworten auf offene Fragen
|
||||
## Antworten auf offenen Fragen
|
||||
|
||||
### 1. Graceful Shutdown
|
||||
**Korrektur:** Der Server hat bereits Graceful Shutdown implementiert (`server/index.js:481-499`). Bei SIGTERM/SIGINT wird allen Clients eine Neustart-Nachricht gesendet, der HTTP-Server geschlossen und nach 5s erzwungen beendet. **Kein Handlungsbedarf.**
|
||||
|
||||
### 2. `/health` Endpoint Rate Limiting
|
||||
Ja, sollte rate-limitiert werden. Aktuell ist `/` ein offener GET-Endpoint. Ein Angreifer könnte diesen endlos pollen. Lösung: Einfacher IP-basierter Rate-Limiter (ähnlich wie bei WebSocket-Connections) mit 60 Requests/Minute pro IP.
|
||||
|
||||
### 3. `setInterval(refreshLogs, 5000)` Last
|
||||
5 Sekunden ist technisch wenig Last (~0.2 IPC calls/sec). Aber es ist **architektonisch unsauber** — der Interval läuft auch wenn der Dev-Tab nicht sichtbar ist. Besser: Nur pollen wenn Dev-Tab aktiv, oder auf `chrome.runtime.onMessage` umstellen (Push statt Poll).
|
||||
|
||||
### 4. `startInterpolation()` Memory Leak
|
||||
**Ja, echtes Problem.** Wenn das Popup geschlossen wird, läuft `interpolationInterval` weiter im Service Worker Kontext. Bei jedem Popup-Öffnen wird ein neuer Interval erstellt (`if (interpolationInterval) return;` schützt nur vor Duplikaten innerhalb derselben Instanz). Über Zeit summieren sich verwaiste Intervals. Fix: `window.addEventListener('unload')` oder Popup-Lifecycle-Listener.
|
||||
|
||||
### 5. `<video>`-Element dynamisch entfernt (SPA-Navigation)
|
||||
Der `MutationObserver` (`content.js:523-532`) erkennt DOM-Änderungen und ruft `checkVideo()` auf. Was passieren sollte:
|
||||
- **Video entfernt:** Event-Listener cleanen, `lastVideoSrc` zurücksetzen, Heartbeat pausieren (kein Video = nichts zu syncen)
|
||||
- **Neues Video erscheint:** `setupListeners()` aufrufen, neue Event-Listener attachen, Heartbeat fortsetzen
|
||||
- **Aktueller Stand:** `checkVideo()` erkennt neue Videos, aber wenn das Video komplett verschwindet, laufen die alten Listener ins Leere und `sendHeartbeat()` returned early (`if (!video) return;`). **Verbesserung:** Explizites Cleanup wenn kein Video mehr gefunden wird.
|
||||
|
||||
### 6. Service Worker Fallback bei Room-State Verlust
|
||||
Manifest V3 suspendiert den Service Worker nach ~30s Inaktivität. `chrome.alarms` weckt ihn auf, aber:
|
||||
- **Problem:** Wenn der SW neu startet, sind alle Variablen (`currentRoom`, `socket`, `isNamespaceJoined`) weg
|
||||
@@ -39,622 +27,15 @@ Stimmt, sind aufwändig. Praktische Ansätze:
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: UI/UX Quick Wins (nächstes Release)
|
||||
|
||||
### 1.1 Toast-Benachrichtigungssystem
|
||||
|
||||
**Ziel:** Zentrales Toast-System für Success/Error/Info/Warning Nachrichten. Ersetzt `showError()` mit Shake-Animation.
|
||||
|
||||
**Dateien:** `extension/popup.html`, `extension/popup.js`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
1. **HTML-Struktur** (in `popup.html`, nach `<body>`):
|
||||
```html
|
||||
<div id="toast-container" style="position:fixed; top:0; left:0; right:0; z-index:9999; display:flex; flex-direction:column; align-items:center; pointer-events:none;"></div>
|
||||
```
|
||||
|
||||
2. **CSS** (in `<style>` in `popup.html`):
|
||||
```css
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-success { background: var(--success); color: white; }
|
||||
.toast-error { background: var(--error); color: white; }
|
||||
.toast-info { background: var(--accent); color: white; }
|
||||
.toast-warning { background: #f59e0b; color: white; }
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toastFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
```
|
||||
|
||||
3. **JavaScript** (in `popup.js`):
|
||||
```javascript
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Integration:**
|
||||
- `showError(msg)` ersetzen durch `showToast(msg, 'error')`
|
||||
- Peer joined/left: `showToast(\`${username} joined\`, 'success')`
|
||||
- Play/Pause/Seek empfangen: `showToast(\`${username} ▶ Play\`, 'info', 2000)`
|
||||
- Force Sync: `showToast('Force Sync initiated', 'warning')`
|
||||
- Connection status: `showToast('Connected', 'success')`
|
||||
|
||||
**Aufwand:** ~80 LOC
|
||||
**Nutzen:** 9/10 — Sofort spürbare UX-Verbesserung
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Tab-Sortierung (Target Tab Dropdown)
|
||||
|
||||
**Ziel:** Target Tab Dropdown sortieren: (1) Aktiver Tab zuerst, (2) Matches, (3) Rest alphabetisch.
|
||||
|
||||
**Dateien:** `extension/popup.js` — Funktion `populateTabs()`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
In `populateTabs()` nach dem Filtern (ca. Zeile 455):
|
||||
|
||||
```javascript
|
||||
// Sortierung: 1. Aktueller Tab, 2. Matches, 3. Rest
|
||||
const currentTabId = currentTargetTabId ? parseInt(currentTargetTabId) : null;
|
||||
|
||||
options.sort((a, b) => {
|
||||
const aId = parseInt(a.value);
|
||||
const bId = parseInt(b.value);
|
||||
|
||||
// 1. Aktiver Tab zuerst
|
||||
if (aId === currentTabId) return -1;
|
||||
if (bId === currentTabId) return 1;
|
||||
|
||||
// 2. Matches vor Nicht-Matches
|
||||
const aMatch = a.textContent.includes('⭐');
|
||||
const bMatch = b.textContent.includes('⭐');
|
||||
if (aMatch && !bMatch) return -1;
|
||||
if (!aMatch && bMatch) return 1;
|
||||
|
||||
// 3. Alphabetisch innerhalb der Gruppen
|
||||
return a.textContent.localeCompare(b.textContent);
|
||||
});
|
||||
```
|
||||
|
||||
**Aufwand:** ~15 LOC
|
||||
**Nutzen:** 7/10 — Intuitivere Tab-Auswahl
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Copy-to-Clipboard Feedback (Invite Link)
|
||||
|
||||
**Ziel:** Besseres visuelles Feedback beim Kopieren des Invite-Links.
|
||||
|
||||
**Dateien:** `extension/popup.js` — `elements.copyInvite` Event Listener
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
Bestehenden Handler (Zeile 893-897) ersetzen:
|
||||
|
||||
```javascript
|
||||
elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.copyInvite.textContent;
|
||||
elements.copyInvite.textContent = '✓';
|
||||
elements.copyInvite.style.background = 'var(--success)';
|
||||
elements.copyInvite.style.color = 'white';
|
||||
showToast('Invite link copied!', 'success', 2000);
|
||||
setTimeout(() => {
|
||||
elements.copyInvite.textContent = original;
|
||||
elements.copyInvite.style.background = '';
|
||||
elements.copyInvite.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Aufwand:** ~10 LOC
|
||||
**Nutzen:** 6/10 — Kleines aber feines Detail
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Last Action Card mit targetTime
|
||||
|
||||
**Ziel:** Bei Seek/Force Sync die `targetTime` in der Last Action Card anzeigen.
|
||||
|
||||
**Dateien:** `extension/popup.js` — Funktion `updateLastActionUI()`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
In `updateLastActionUI()` nach dem Header (ca. Zeile 179):
|
||||
|
||||
```javascript
|
||||
// targetTime für Seek/Force Sync anzeigen
|
||||
if (state.targetTime !== undefined && state.action === 'seek') {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
```
|
||||
|
||||
**Aufwand:** ~15 LOC
|
||||
**Nutzen:** 7/10 — Mehr Kontext bei Sync-Actions
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Verbesserte Empty States
|
||||
|
||||
**Ziel:** Aussagekräftigere Empty States mit Icons und Hilfetexten.
|
||||
|
||||
**Dateien:** `extension/popup.html`, `extension/popup.js`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
Ersetze generische "No peers connected" / "No activity yet" Texte:
|
||||
|
||||
```javascript
|
||||
// In updatePeerList() und updateHistory():
|
||||
const emptyStates = {
|
||||
peers: {
|
||||
icon: '👥',
|
||||
title: 'No peers yet',
|
||||
hint: 'Share your invite link to get started'
|
||||
},
|
||||
history: {
|
||||
icon: '📋',
|
||||
title: 'No activity yet',
|
||||
hint: 'Play, pause, or seek to see history'
|
||||
},
|
||||
logs: {
|
||||
icon: '📝',
|
||||
title: 'No logs',
|
||||
hint: 'Connection events will appear here'
|
||||
},
|
||||
rooms: {
|
||||
icon: '🔍',
|
||||
title: 'No active rooms',
|
||||
hint: 'Create a room or refresh to find public ones'
|
||||
}
|
||||
};
|
||||
|
||||
// Render-Funktion:
|
||||
function renderEmpty(container, type) {
|
||||
const state = emptyStates[type];
|
||||
container.innerHTML = `
|
||||
<div style="text-align:center; padding:16px 8px; color:var(--text-muted);">
|
||||
<div style="font-size:24px; margin-bottom:6px;">${state.icon}</div>
|
||||
<div style="font-size:12px; font-weight:600; margin-bottom:4px;">${state.title}</div>
|
||||
<div style="font-size:10px; opacity:0.7;">${state.hint}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
**Aufwand:** ~40 LOC
|
||||
**Nutzen:** 7/10 — Professionellerer Look
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Onboarding Tour
|
||||
|
||||
**Ziel:** Beim ersten Start kurze Erklärung der 4 Tabs mit Tooltips.
|
||||
|
||||
**Dateien:** `extension/popup.html`, `extension/popup.js`, `extension/background.js`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
1. **Storage-Flag** (in `background.js` beim ersten Start):
|
||||
```javascript
|
||||
// In restoreSession() oder init():
|
||||
const data = await chrome.storage.sync.get(['onboardingComplete']);
|
||||
if (!data.onboardingComplete) {
|
||||
chrome.runtime.sendMessage({ type: 'SHOW_ONBOARDING' });
|
||||
}
|
||||
```
|
||||
|
||||
2. **Onboarding-Overlay** (in `popup.html`, am Ende von `<body>`):
|
||||
```html
|
||||
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; display:flex; align-items:center; justify-content:center;">
|
||||
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; text-align:center;">
|
||||
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">👋</div>
|
||||
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px;">Welcome to KoalaSync!</h2>
|
||||
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px;">Let's get you started.</p>
|
||||
<div style="display:flex; gap:8px; justify-content:center;">
|
||||
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;">Next</button>
|
||||
</div>
|
||||
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **JavaScript** (in `popup.js`):
|
||||
```javascript
|
||||
const onboardingSteps = [
|
||||
{ icon: '🏠', title: 'Room Tab', text: 'Create or join a room to sync with friends. Share the invite link!' },
|
||||
{ icon: '🎬', title: 'Sync Tab', text: 'Select your video tab and control playback. Force Sync fixes drift.' },
|
||||
{ icon: '⚙️', title: 'Settings', text: 'Customize your username, filter noise tabs, and toggle auto-sync.' },
|
||||
{ icon: '🔧', title: 'Dev Tab', text: 'Debug connection status, video state, and view action history.' }
|
||||
];
|
||||
|
||||
let onboardingStep = 0;
|
||||
|
||||
function showOnboarding() {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
overlay.style.display = 'flex';
|
||||
renderOnboardingStep();
|
||||
}
|
||||
|
||||
function renderOnboardingStep() {
|
||||
const step = onboardingSteps[onboardingStep];
|
||||
document.getElementById('onboarding-icon').textContent = step.icon;
|
||||
document.getElementById('onboarding-title').textContent = step.title;
|
||||
document.getElementById('onboarding-text').textContent = step.text;
|
||||
|
||||
// Dots
|
||||
const dots = document.getElementById('onboarding-dots');
|
||||
dots.innerHTML = onboardingSteps.map((_, i) =>
|
||||
`<div style="width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};"></div>`
|
||||
).join('');
|
||||
|
||||
document.getElementById('onboarding-next').textContent =
|
||||
onboardingStep === onboardingSteps.length - 1 ? 'Done!' : 'Next';
|
||||
}
|
||||
|
||||
document.getElementById('onboarding-next').addEventListener('click', () => {
|
||||
onboardingStep++;
|
||||
if (onboardingStep >= onboardingSteps.length) {
|
||||
completeOnboarding();
|
||||
} else {
|
||||
renderOnboardingStep();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('onboarding-skip').addEventListener('click', completeOnboarding);
|
||||
|
||||
function completeOnboarding() {
|
||||
document.getElementById('onboarding-overlay').style.display = 'none';
|
||||
chrome.storage.sync.set({ onboardingComplete: true });
|
||||
}
|
||||
|
||||
// In init():
|
||||
chrome.storage.sync.get(['onboardingComplete'], (data) => {
|
||||
if (!data.onboardingComplete) showOnboarding();
|
||||
});
|
||||
```
|
||||
|
||||
**Aufwand:** ~120 LOC
|
||||
**Nutzen:** 8/10 — Reduziert Abbruchrate neuer Nutzer
|
||||
|
||||
---
|
||||
|
||||
### 1.7 Browser Notifications + Toasts für Sync-Events
|
||||
|
||||
**Ziel:** System-Benachrichtigungen bei Peer-Events (opt-in) UND Toast-Nachrichten für alle Sync-Events.
|
||||
|
||||
**Dateien:** `extension/popup.html`, `extension/popup.js`, `extension/background.js`, `extension/manifest.base.json`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
1. **Permission** (in `manifest.base.json`):
|
||||
```json
|
||||
"permissions": ["storage", "tabs", "scripting", "alarms", "notifications"]
|
||||
```
|
||||
|
||||
2. **Settings Toggle** (in `popup.html`, Settings Tab):
|
||||
```html
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0;">Browser Notifications</label>
|
||||
<input type="checkbox" id="browserNotifications" style="width: auto;">
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Background.js — Notification Handler:**
|
||||
```javascript
|
||||
function notifyPeerEvent(event, data) {
|
||||
chrome.storage.sync.get(['browserNotifications'], (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
|
||||
const notifications = {
|
||||
'joined': { title: 'Peer Joined', body: `${data.username || data.peerId} joined the room` },
|
||||
'left': { title: 'Peer Left', body: `${data.username || data.peerId} left the room` },
|
||||
'play': { title: 'Playback Started', body: `${data.senderId} pressed Play` },
|
||||
'pause': { title: 'Playback Paused', body: `${data.senderId} pressed Pause` },
|
||||
'force_sync': { title: 'Force Sync', body: `${data.senderId} initiated Force Sync` }
|
||||
};
|
||||
|
||||
const config = notifications[event];
|
||||
if (!config) return;
|
||||
|
||||
chrome.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: config.title,
|
||||
message: config.body,
|
||||
priority: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
4. **Popup.js — Toasts für alle Events:**
|
||||
```javascript
|
||||
// In chrome.runtime.onMessage.addListener():
|
||||
if (msg.type === 'PEER_UPDATE') {
|
||||
updatePeerList(msg.peers);
|
||||
// Detect join/leave durch Vergleich mit vorheriger Liste
|
||||
detectPeerChanges(msg.peers);
|
||||
}
|
||||
|
||||
function detectPeerChanges(newPeers) {
|
||||
const oldIds = new Set(lastKnownPeers.map(p => p.peerId || p));
|
||||
const newIds = new Set(newPeers.map(p => p.peerId || p));
|
||||
|
||||
// Neue Peers
|
||||
for (const peer of newPeers) {
|
||||
const id = peer.peerId || peer;
|
||||
if (!oldIds.has(id)) {
|
||||
const name = peer.username || id.substring(0, 4);
|
||||
showToast(`${name} joined the room`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// Verlassene Peers
|
||||
for (const oldPeer of lastKnownPeers) {
|
||||
const id = oldPeer.peerId || oldPeer;
|
||||
if (!newIds.has(id)) {
|
||||
const name = oldPeer.username || id.substring(0, 4);
|
||||
showToast(`${name} left the room`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
lastKnownPeers = newPeers;
|
||||
}
|
||||
```
|
||||
|
||||
5. **Toast für Play/Pause/Seek:**
|
||||
```javascript
|
||||
// In onMessage für ACTION_UPDATE:
|
||||
if (msg.type === 'ACTION_UPDATE') {
|
||||
const state = msg.state;
|
||||
if (state && state.senderId !== 'You') {
|
||||
const actionNames = {
|
||||
'play': '▶ Play',
|
||||
'pause': '⏸ Pause',
|
||||
'seek': '⏩ Seek',
|
||||
'force_sync_prepare': '⚡ Force Sync',
|
||||
'force_sync_execute': '⚡ Force Play'
|
||||
};
|
||||
const action = actionNames[state.action] || state.action;
|
||||
showToast(`${state.senderId} ${action}`, 'info', 2000);
|
||||
}
|
||||
// ... existing updateLastActionUI call
|
||||
}
|
||||
```
|
||||
|
||||
**Aufwand:** ~150 LOC
|
||||
**Nutzen:** 9/10 — Kritisch für UX, Nutzer wissen was passiert
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Quality of Life Fixes
|
||||
|
||||
### 2.1 Interpolation Cleanup (Memory Leak Fix)
|
||||
|
||||
**Ziel:** `interpolationInterval` beim Popup-Schließen bereinigen.
|
||||
|
||||
**Dateien:** `extension/popup.js`
|
||||
|
||||
**Implementierung:**
|
||||
```javascript
|
||||
// In init() oder am Ende der Datei:
|
||||
window.addEventListener('unload', () => {
|
||||
stopInterpolation();
|
||||
});
|
||||
|
||||
// Oder für Browser Extensions spezifisch:
|
||||
chrome.runtime.onSuspend.addListener(() => {
|
||||
stopInterpolation();
|
||||
});
|
||||
```
|
||||
|
||||
**Aufwand:** ~5 LOC
|
||||
**Nutzen:** 8/10 — Verhindert Memory Leak
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Dev-Tab Logs nur bei Sichtbarkeit pollen
|
||||
|
||||
**Ziel:** `refreshLogs` nur ausführen wenn Dev-Tab sichtbar.
|
||||
|
||||
**Dateien:** `extension/popup.js`
|
||||
|
||||
**Implementierung:**
|
||||
```javascript
|
||||
// Ersetze setInterval(refreshLogs, 5000) durch:
|
||||
let isDevTabVisible = false;
|
||||
|
||||
elements.tabs.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
// ... existing tab switch logic
|
||||
isDevTabVisible = btn.dataset.tab === 'tab-dev';
|
||||
if (isDevTabVisible) refreshLogs();
|
||||
});
|
||||
});
|
||||
|
||||
// Poll nur wenn sichtbar
|
||||
setInterval(() => {
|
||||
if (isDevTabVisible) refreshLogs();
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
**Aufwand:** ~10 LOC
|
||||
**Nutzen:** 5/10 — Kleine Optimierung
|
||||
|
||||
---
|
||||
|
||||
### 2.3 content.js: Explizites Video-Cleanup
|
||||
|
||||
**Ziel:** Wenn `<video>`-Element entfernt wird, Listener cleanen und Heartbeat pausieren.
|
||||
|
||||
**Dateien:** `extension/content.js`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
In `checkVideo()` (ca. Zeile 507):
|
||||
|
||||
```javascript
|
||||
function checkVideo() {
|
||||
lastMutate = Date.now();
|
||||
const video = findVideo();
|
||||
|
||||
// Video removed — cleanup
|
||||
if (!video && lastVideoSrc) {
|
||||
reportLog('Video element removed from page', 'warn');
|
||||
lastVideoSrc = null;
|
||||
// Heartbeat returns early automatically (if (!video) return)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video) return;
|
||||
|
||||
const currentSrc = video.currentSrc || video.src;
|
||||
|
||||
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
|
||||
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
|
||||
checkEpisodeTransition();
|
||||
}
|
||||
setupListeners();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Aufwand:** ~10 LOC
|
||||
**Nutzen:** 7/10 — Saubereres Verhalten bei SPA-Navigation
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `/health` Endpoint Rate Limiting
|
||||
|
||||
**Ziel:** Den offenen `/` Endpoint gegen IP-basiertes Rate Limiting schützen (60 req/min pro IP).
|
||||
|
||||
**Dateien:** `server/index.js`
|
||||
|
||||
**Implementierung:**
|
||||
|
||||
In `server/index.js`, nach den bestehenden Rate-Limiting Maps (ca. Zeile 47):
|
||||
|
||||
```javascript
|
||||
// Health Endpoint Rate Limiting
|
||||
const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
|
||||
function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
return entry.count <= 60; // 60 requests per minute
|
||||
}
|
||||
|
||||
// Cleanup health counts im bestehenden Cleanup-Interval (Zeile 89):
|
||||
// Füge hinzu in der setInterval cleanup loop:
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
healthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bestehenden `/` Endpoint (Zeile 19) ersetzen:
|
||||
|
||||
```javascript
|
||||
app.get('/', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Too many requests. Try again later.' });
|
||||
}
|
||||
res.json({ status: 'online', service: 'KoalaSync Relay', version: process.env.npm_package_version || 'unknown' });
|
||||
});
|
||||
```
|
||||
|
||||
Optional: Dedizierten `/health` Endpoint für Docker/Monitoring hinzufügen:
|
||||
|
||||
```javascript
|
||||
app.get('/health', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
rooms: rooms.size,
|
||||
connections: io.engine.clientsCount,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Aufwand:** ~25 LOC
|
||||
**Nutzen:** 7/10 — Verhindert Missbrauch des offenen Endpoints
|
||||
|
||||
---
|
||||
|
||||
## Priorisierung
|
||||
|
||||
| Priority | Feature | Aufwand | Nutzen |
|
||||
|----------|---------|---------|--------|
|
||||
| 🔴 Hoch | Toast-Benachrichtigungen | ~80 LOC | 9/10 |
|
||||
| 🔴 Hoch | Browser Notifications + Event Toasts | ~150 LOC | 9/10 |
|
||||
| 🟡 Mittel | Onboarding Tour | ~120 LOC | 8/10 |
|
||||
| 🟡 Mittel | Interpolation Cleanup | ~5 LOC | 8/10 |
|
||||
| 🟡 Mittel | `/health` Rate Limiting | ~25 LOC | 7/10 |
|
||||
| 🟡 Mittel | Verbesserte Empty States | ~40 LOC | 7/10 |
|
||||
| 🟡 Mittel | Last Action Card mit targetTime | ~15 LOC | 7/10 |
|
||||
| 🟡 Mittel | content.js Video Cleanup | ~10 LOC | 7/10 |
|
||||
| 🟢 Niedrig | Tab-Sortierung | ~15 LOC | 7/10 |
|
||||
| 🟢 Niedrig | Copy-to-Clipboard Feedback | ~10 LOC | 6/10 |
|
||||
| 🟢 Niedrig | Dev-Tab Logs Sichtbarkeit | ~10 LOC | 5/10 |
|
||||
|
||||
**Gesamtaufwand Phase 1+2:** ~480 LOC
|
||||
**Geschätzte Zeit:** 2-4 Tage
|
||||
|
||||
---
|
||||
|
||||
## Zukünftige Features (spätere Releases)
|
||||
|
||||
Diese Features wurden evaluiert aber sind nicht Teil der nächsten Release:
|
||||
|
||||
| Feature | Aufwand | Nutzen | Status |
|
||||
|---------|---------|--------|--------|
|
||||
| Verbesserte Empty States | ~40 LOC | 7/10 | Backlog |
|
||||
| Onboarding Tour | ~120 LOC | 8/10 | Backlog |
|
||||
| Dev-Tab Logs nur bei Sichtbarkeit pollen | ~10 LOC | 5/10 | Backlog |
|
||||
| Chat im Room | ~400 LOC | 9/10 | Geplant v1.6 |
|
||||
| Playback Speed Sync | ~150 LOC | 8/10 | Geplant v1.5 |
|
||||
| Room Host/Owner | ~350 LOC | 8/10 | Geplant v1.6 |
|
||||
|
||||
+21
-18
@@ -411,25 +411,28 @@ function updateBadgeStatus() {
|
||||
}
|
||||
|
||||
function showNotification(senderName, action) {
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_prepare' ? 'started force sync' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
// Find username in current room if available
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
chrome.storage.sync.get(['browserNotifications'], (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_prepare' ? 'started force sync' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -507,6 +507,13 @@
|
||||
function checkVideo() {
|
||||
lastMutate = Date.now();
|
||||
const video = findVideo();
|
||||
|
||||
if (!video && lastVideoSrc) {
|
||||
reportLog('Video element removed from page', 'warn');
|
||||
lastVideoSrc = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video) return;
|
||||
|
||||
const currentSrc = video.currentSrc || video.src;
|
||||
|
||||
@@ -199,9 +199,48 @@
|
||||
}
|
||||
.invite-box input { flex: 1; font-size: 11px; }
|
||||
.invite-box button { width: 40px; padding: 0; }
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-success { background: var(--success); color: white; }
|
||||
.toast-error { background: var(--error); color: white; }
|
||||
.toast-info { background: var(--accent); color: white; }
|
||||
.toast-warning { background: #f59e0b; color: white; }
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toastFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toast-container"></div>
|
||||
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -332,6 +371,11 @@
|
||||
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
|
||||
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0;">Browser Notifications</label>
|
||||
<input type="checkbox" id="browserNotifications" style="width: auto;">
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
|
||||
<p>• Username helps others identify you.</p>
|
||||
|
||||
+104
-18
@@ -45,16 +45,18 @@ const elements = {
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
lobbyTitle: document.getElementById('lobbyTitle'),
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus')
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
browserNotifications: document.getElementById('browserNotifications')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
let lastPeersJson = null;
|
||||
let lastKnownPeers = [];
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode']);
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic'];
|
||||
@@ -70,6 +72,7 @@ async function init() {
|
||||
elements.filterNoise.checked = data.filterNoise !== false;
|
||||
elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
|
||||
elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
|
||||
elements.browserNotifications.checked = data.browserNotifications === true;
|
||||
|
||||
// Set Version Info
|
||||
const versionEl = document.getElementById('appVersion');
|
||||
@@ -178,6 +181,20 @@ function updateLastActionUI(state, peers) {
|
||||
header.appendChild(infoSpan);
|
||||
elements.lastActionCard.appendChild(header);
|
||||
|
||||
if (state.targetTime !== undefined && state.action === 'seek') {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 5px;';
|
||||
|
||||
@@ -389,6 +406,29 @@ function updatePeerList(peers) {
|
||||
populateTabs(peers);
|
||||
}
|
||||
|
||||
function detectPeerChanges(newPeers) {
|
||||
const oldIds = new Set(lastKnownPeers.map(p => p.peerId || p));
|
||||
const newIds = new Set(newPeers.map(p => p.peerId || p));
|
||||
|
||||
for (const peer of newPeers) {
|
||||
const id = peer.peerId || peer;
|
||||
if (!oldIds.has(id)) {
|
||||
const name = peer.username || id.substring(0, 4);
|
||||
showToast(`${name} joined the room`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
for (const oldPeer of lastKnownPeers) {
|
||||
const id = oldPeer.peerId || oldPeer;
|
||||
if (!newIds.has(id)) {
|
||||
const name = oldPeer.username || id.substring(0, 4);
|
||||
showToast(`${name} left the room`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
lastKnownPeers = newPeers;
|
||||
}
|
||||
|
||||
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const data = await chrome.storage.sync.get(['filterNoise']);
|
||||
const isFilterActive = data.filterNoise !== false;
|
||||
@@ -451,10 +491,25 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
elements.targetTab.appendChild(option);
|
||||
});
|
||||
|
||||
// Sort: Matches first
|
||||
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
|
||||
const options = Array.from(elements.targetTab.options);
|
||||
const placeholder = options.shift(); // Remove placeholder
|
||||
options.sort((a, b) => (b.textContent.includes('⭐') ? 1 : 0) - (a.textContent.includes('⭐') ? 1 : 0));
|
||||
const placeholder = options.shift();
|
||||
const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null;
|
||||
|
||||
options.sort((a, b) => {
|
||||
const aId = parseInt(a.value);
|
||||
const bId = parseInt(b.value);
|
||||
|
||||
if (aId === currentTabId) return -1;
|
||||
if (bId === currentTabId) return 1;
|
||||
|
||||
const aMatch = a.textContent.includes('⭐');
|
||||
const bMatch = b.textContent.includes('⭐');
|
||||
if (aMatch && !bMatch) return -1;
|
||||
if (!aMatch && bMatch) return 1;
|
||||
|
||||
return a.textContent.localeCompare(b.textContent);
|
||||
});
|
||||
elements.targetTab.innerHTML = '';
|
||||
elements.targetTab.appendChild(placeholder);
|
||||
options.forEach(opt => elements.targetTab.appendChild(opt));
|
||||
@@ -664,6 +719,10 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
elements.browserNotifications.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
|
||||
});
|
||||
|
||||
elements.forceSyncMode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
|
||||
});
|
||||
@@ -695,6 +754,16 @@ elements.tabs.forEach(btn => {
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
if (!elements.roomError) return;
|
||||
elements.roomError.textContent = msg;
|
||||
@@ -702,16 +771,7 @@ function showError(msg) {
|
||||
elements.roomId.style.borderColor = 'var(--error)';
|
||||
elements.password.style.borderColor = 'var(--error)';
|
||||
|
||||
// Shake effect
|
||||
const activeTab = document.querySelector('.tab-content.active');
|
||||
if (activeTab) {
|
||||
activeTab.animate([
|
||||
{ transform: 'translateX(0)' },
|
||||
{ transform: 'translateX(-5px)' },
|
||||
{ transform: 'translateX(5px)' },
|
||||
{ transform: 'translateX(0)' }
|
||||
], { duration: 200, iterations: 2 });
|
||||
}
|
||||
showToast(msg, 'error', 5000);
|
||||
|
||||
setTimeout(() => {
|
||||
if (elements.roomError) elements.roomError.style.display = 'none';
|
||||
@@ -891,9 +951,18 @@ elements.clearLogs.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value);
|
||||
elements.copyInvite.textContent = '✅';
|
||||
setTimeout(() => { elements.copyInvite.textContent = '📋'; }, 2000);
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.copyInvite.textContent;
|
||||
elements.copyInvite.textContent = '✓';
|
||||
elements.copyInvite.style.background = 'var(--success)';
|
||||
elements.copyInvite.style.color = 'white';
|
||||
showToast('Invite link copied!', 'success', 2000);
|
||||
setTimeout(() => {
|
||||
elements.copyInvite.textContent = original;
|
||||
elements.copyInvite.style.background = '';
|
||||
elements.copyInvite.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Logs & Status ---
|
||||
@@ -919,11 +988,24 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
showError(msg.log.message);
|
||||
}
|
||||
} else if (msg.type === 'ACTION_UPDATE') {
|
||||
const state = msg.state;
|
||||
if (state && state.senderId && state.senderId !== 'You') {
|
||||
const actionNames = {
|
||||
'play': '▶ Play',
|
||||
'pause': '⏸ Pause',
|
||||
'seek': '⏩ Seek',
|
||||
'force_sync_prepare': '⚡ Force Sync',
|
||||
'force_sync_execute': '⚡ Force Play'
|
||||
};
|
||||
const action = actionNames[state.action] || state.action;
|
||||
showToast(`${state.senderId} ${action}`, 'info', 2000);
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.peers) updateLastActionUI(msg.state, res.peers);
|
||||
});
|
||||
} else if (msg.type === 'PEER_UPDATE') {
|
||||
updatePeerList(msg.peers);
|
||||
if (msg.peers) detectPeerChanges(msg.peers);
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
applyConnectionStatus(msg.status);
|
||||
if (msg.status === 'connected') {
|
||||
@@ -1054,6 +1136,10 @@ function refreshDebugInfo() {
|
||||
init();
|
||||
setInterval(refreshLogs, 5000);
|
||||
|
||||
window.addEventListener('unload', () => {
|
||||
stopInterpolation();
|
||||
});
|
||||
|
||||
// --- Episode Lobby UI ---
|
||||
function updateLobbyUI(lobby, peers) {
|
||||
if (!elements.episodeLobbyCard) return;
|
||||
|
||||
+37
-2
@@ -15,8 +15,28 @@ const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
|
||||
const app = express();
|
||||
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
||||
|
||||
// Health Check
|
||||
app.get('/', (req, res) => res.json({ status: 'online', service: 'KoalaSync Relay' }));
|
||||
// Health Check with Rate Limiting
|
||||
app.get('/', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Too many requests. Try again later.' });
|
||||
}
|
||||
res.json({ status: 'online', service: 'KoalaSync Relay' });
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
rooms: rooms.size,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
|
||||
@@ -84,6 +104,7 @@ setInterval(() => {
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
|
||||
// Clean up connection counts and event counts to prevent memory leak
|
||||
setInterval(() => {
|
||||
@@ -98,6 +119,11 @@ setInterval(() => {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
healthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function checkConnectionRate(ip) {
|
||||
@@ -118,6 +144,15 @@ function checkEventRate(socketId) {
|
||||
return entry.count <= 30;
|
||||
}
|
||||
|
||||
function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
return entry.count <= 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central peer teardown. Removes a socket from all room state and notifies
|
||||
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
||||
|
||||
Reference in New Issue
Block a user