Initial commit

This commit is contained in:
Francesco Lorenzo D'Amico
2026-08-10 15:18:25 +02:00
parent 8fdb425402
commit a5d116358e
15 changed files with 700 additions and 165 deletions
+12
View File
@@ -0,0 +1,12 @@
* text=auto
Dockerfile text eol=lf
docker-compose.yml text eol=lf
*.sh text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.conf text eol=lf
-72
View File
@@ -1,72 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ChangeListManager">
<list default="true" id="183cc890-1dbc-48d5-8436-4d83b3671444" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="CopilotPersistence">
<persistenceIdMap>
<entry key="_//wsl.localhost/Ubuntu-20.04/home/francescod/dev/Chat-App" value="3Aw7oEkK1nQjM1LVTrOnDRFvCjP" />
</persistenceIdMap>
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 3
}]]></component>
<component name="ProjectId" id="3Aw7oEkK1nQjM1LVTrOnDRFvCjP" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"javascript.preferred.runtime.type.id": "node",
"last_opened_file_path": "//wsl.localhost/Ubuntu-20.04/home/francescod/dev/Chat-App",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-20.04\home\francescod\dev\Chat-App\Backend" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-WS-253.31033.133" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="183cc890-1dbc-48d5-8436-4d83b3671444" name="Changes" comment="" />
<created>1751619297246</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1751619297246</updated>
<workItem from="1773491953643" duration="2831000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>
+17
View File
@@ -1,15 +1,32 @@
# Docker lädt ein fertiges Image mit Linux Alpine und Node.js Version 20.
# Alpine ist eine sehr kleine Linux-Distribution — kleineres Image, schnellere Downloads.
FROM node:20-alpine FROM node:20-alpine
# Docker erstellt Verzeichnis /app falls noch nicht vorhanden.
# Alle folgenden Befehle laufen in diesem Verzeichnis.
WORKDIR /app WORKDIR /app
# Zuerst nur package.json kopieren — noch nicht den ganzen Code.
# Warum: Docker cached jeden Schritt. Wenn sich nur der Code ändert aber nicht die
# Dependencies, muss npm ci nicht nochmal laufen — Build wird schneller (Layer Caching).
COPY package*.json ./ COPY package*.json ./
# npm ci installiert exakt die Versionen aus package-lock.json — reproduzierbarer Build.
# --omit=dev: Keine Entwicklungs-Dependencies in Produktion — kleineres und sichereres Image.
RUN npm ci --omit=dev RUN npm ci --omit=dev
# Jetzt erst den restlichen Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
COPY . . COPY . .
# NODE_ENV=production aktiviert Optimierungen in Express (z.B. bessere Performance, weniger Logs).
# PORT=4000 definiert auf welchem Port der Server lauscht.
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=4000 ENV PORT=4000
# Dokumentiert welchen Port der Container verwendet — öffnet ihn aber nicht wirklich.
# Das passiert in docker-compose.yml.
EXPOSE 4000 EXPOSE 4000
# Startbefehl wenn der Container läuft.
# Array-Form statt String damit Node.js direkt als Prozess gestartet wird — kein Shell-Prozess dazwischen.
CMD ["node", "index.js"] CMD ["node", "index.js"]
+37 -3
View File
@@ -9,6 +9,11 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
}, },
}) })
const typingByRoom = new Map()
// io.use: Middleware die bei jeder eingehenden WebSocket-Verbindung läuft.
// Prüft ob room und userId im Handshake vorhanden sind und ob der User in der DB existiert.
// Wer vorher nicht POST /join aufgerufen hat, hat keine gültige userId und wird abgelehnt.
io.use(async (socket, next) => { io.use(async (socket, next) => {
try { try {
const auth = socket.handshake.auth || {} const auth = socket.handshake.auth || {}
@@ -25,6 +30,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
return next(new Error('User not authorized for this room')) return next(new Error('User not authorized for this room'))
} }
// room und userId in socket.data speichern — so sind sie in registerConnection verfügbar.
socket.data.room = room socket.data.room = room
socket.data.userId = userId socket.data.userId = userId
next() next()
@@ -34,7 +40,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
}) })
io.on('connection', (socket) => { io.on('connection', (socket) => {
registerConnection(io, socket, database, onError).catch((error) => { registerConnection(io, socket, database, onError, typingByRoom).catch((error) => {
onError('Socket connection setup failed', error) onError('Socket connection setup failed', error)
socket.disconnect(true) socket.disconnect(true)
}) })
@@ -43,8 +49,10 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
return io return io
} }
async function registerConnection(io, socket, database, onError) { async function registerConnection(io, socket, database, onError, typingByRoom) {
const { room, userId } = socket.data const { room, userId } = socket.data
// socket.join: Fügt den Socket einem Room-Kanal hinzu — io.to(room).emit() erreicht nur diese Sockets.
socket.join(room) socket.join(room)
const user = await database.setUserOnline(userId, room) const user = await database.setUserOnline(userId, room)
@@ -53,10 +61,25 @@ async function registerConnection(io, socket, database, onError) {
return return
} }
socket.data.username = user.username
socket.emit('connection:ready', { userId, room }) socket.emit('connection:ready', { userId, room })
socket.emit('history:init', await database.getMessages(room))
// Teilnehmerliste an alle im Room senden — User sieht sofort wer online ist.
await broadcastParticipants(io, database, room) await broadcastParticipants(io, database, room)
// typingByRoom: Map pro Room — speichert wer gerade tippt { userId → { userId, username } }.
socket.on('typing:start', () => {
if (!typingByRoom.has(room)) typingByRoom.set(room, new Map())
typingByRoom.get(room).set(userId, { userId, username: socket.data.username })
io.to(room).emit('typing:update', [...typingByRoom.get(room).values()])
})
socket.on('typing:stop', () => {
typingByRoom.get(room)?.delete(userId)
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
})
socket.on('message:send', async (payload) => { socket.on('message:send', async (payload) => {
try { try {
const text = const text =
@@ -65,11 +88,16 @@ async function registerConnection(io, socket, database, onError) {
return return
} }
// Nochmal prüfen ob der User noch im richtigen Room ist — könnte sich seit dem Verbinden geändert haben.
const activeUser = await database.getUser(userId) const activeUser = await database.getUser(userId)
if (!activeUser || activeUser.room !== room) { if (!activeUser || activeUser.room !== room) {
return return
} }
// Tipp-Indikator sofort entfernen wenn eine Nachricht gesendet wird.
typingByRoom.get(room)?.delete(userId)
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
const message = await database.addMessage({ const message = await database.addMessage({
id: randomUUID(), id: randomUUID(),
room, room,
@@ -77,6 +105,7 @@ async function registerConnection(io, socket, database, onError) {
username: activeUser.username, username: activeUser.username,
text: text.trim(), text: text.trim(),
}) })
// Nachricht an alle im Room senden — inkl. Sender selbst.
io.to(room).emit('message:new', message) io.to(room).emit('message:new', message)
} catch (error) { } catch (error) {
onError('Failed to handle socket message', error) onError('Failed to handle socket message', error)
@@ -84,6 +113,9 @@ async function registerConnection(io, socket, database, onError) {
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {
typingByRoom.get(room)?.delete(userId)
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
// User als offline markieren und Teilnehmerliste aktualisieren.
database database
.setUserOffline(userId) .setUserOffline(userId)
.then(() => broadcastParticipants(io, database, room)) .then(() => broadcastParticipants(io, database, room))
@@ -100,6 +132,8 @@ async function broadcastParticipants(io, database, room) {
io.to(room).emit('participants:update', participants) io.to(room).emit('participants:update', participants)
} }
// Liest einen Parameter zuerst aus der primären Quelle (auth), dann aus dem Fallback (query).
// Schützt gegen leere Strings — nur echte Werte werden akzeptiert.
function readParam(primary, fallback) { function readParam(primary, fallback) {
if (typeof primary === 'string' && primary.trim()) return primary if (typeof primary === 'string' && primary.trim()) return primary
if (typeof fallback === 'string' && fallback.trim()) return fallback if (typeof fallback === 'string' && fallback.trim()) return fallback
+35 -3
View File
@@ -1,33 +1,44 @@
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
// JsonStore: Dateibasierter Fallback-Store wenn kein MongoDB vorhanden ist.
// Alle Daten liegen als JSON-Datei auf der Festplatte — kein externer Datenbankserver nötig.
// Nachteil gegenüber MongoDB: Kein Concurrency-Schutz bei mehreren gleichzeitigen Schreiboperationen.
class JsonStore { class JsonStore {
constructor(filePath) { constructor(filePath) {
this.filePath = filePath this.filePath = filePath
this.state = { users: [], messages: [] } this.state = { users: [], messages: [] } // In-Memory-State — alle Operationen laufen darauf.
this._ensureStore() this._ensureStore()
} }
// Stellt sicher dass die JSON-Datei und das Verzeichnis existieren.
// Wenn die Datei beschädigt ist (kein gültiges JSON), wird sie neu erstellt.
_ensureStore() { _ensureStore() {
const dir = path.dirname(this.filePath) const dir = path.dirname(this.filePath)
fs.mkdirSync(dir, { recursive: true }) fs.mkdirSync(dir, { recursive: true }) // Verzeichnis anlegen falls nicht vorhanden.
if (!fs.existsSync(this.filePath)) { if (!fs.existsSync(this.filePath)) {
this._persist() this._persist()
return return
} }
try { try {
const contents = fs.readFileSync(this.filePath, 'utf-8') const contents = fs.readFileSync(this.filePath, 'utf-8')
this.state = JSON.parse(contents) this.state = JSON.parse(contents) // Datei in den In-Memory-State laden.
} catch (error) { } catch (error) {
// Datei vorhanden aber nicht lesbar oder kein gültiges JSON — neu erstellen.
console.warn('JSON store damaged, recreating', error) console.warn('JSON store damaged, recreating', error)
this._persist() this._persist()
} }
} }
// Schreibt den aktuellen In-Memory-State in die JSON-Datei.
// null, 2 formatiert das JSON mit Einrückung — besser lesbar beim Debugging.
_persist() { _persist() {
fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8') fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8')
} }
// Upsert: User aktualisieren wenn vorhanden, sonst neu anlegen.
// Spread-Operator {...this.state.users[index], ...} übernimmt alle bestehenden Felder
// und überschreibt nur die geänderten — joinedAt bleibt beim Update erhalten.
async upsertUser({ id, username, room }) { async upsertUser({ id, username, room }) {
const trimmed = username?.trim() const trimmed = username?.trim()
if (!trimmed) { if (!trimmed) {
@@ -60,6 +71,7 @@ class JsonStore {
return user return user
} }
// Object.assign mutiert das gefundene Objekt direkt im Array — kein Index-Lookup nötig.
async setUserOnline(userId, room) { async setUserOnline(userId, room) {
const user = this.state.users.find((entry) => entry.id === userId) const user = this.state.users.find((entry) => entry.id === userId)
if (!user) { if (!user) {
@@ -74,6 +86,7 @@ class JsonStore {
return user return user
} }
// lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
async setUserOffline(userId) { async setUserOffline(userId) {
const user = this.state.users.find((entry) => entry.id === userId) const user = this.state.users.find((entry) => entry.id === userId)
if (!user) { if (!user) {
@@ -96,6 +109,8 @@ class JsonStore {
return user return user
} }
// splice(index, 1) entfernt genau ein Element an der gefundenen Position.
// Destructuring [removed] holt das gelöschte Element aus dem zurückgegebenen Array.
async removeUser(userId) { async removeUser(userId) {
const index = this.state.users.findIndex((entry) => entry.id === userId) const index = this.state.users.findIndex((entry) => entry.id === userId)
if (index === -1) return null if (index === -1) return null
@@ -108,6 +123,8 @@ class JsonStore {
return this.state.users.find((entry) => entry.id === userId) || null return this.state.users.find((entry) => entry.id === userId) || null
} }
// .map() gibt nur die öffentlichen Felder zurück — interne Felder werden nicht nach außen gegeben.
// Sortierung: Online-User zuerst, dann nach letzter Aktivität absteigend.
async getParticipants(room) { async getParticipants(room) {
return this.state.users return this.state.users
.filter((entry) => entry.room === room) .filter((entry) => entry.room === room)
@@ -126,6 +143,7 @@ class JsonStore {
}) })
} }
// createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
async addMessage({ id, room, userId, username, text }) { async addMessage({ id, room, userId, username, text }) {
const message = { const message = {
id, id,
@@ -140,11 +158,25 @@ class JsonStore {
return message return message
} }
// slice(-limit) gibt die letzten N Nachrichten zurück — neueste am Ende, älteste zuerst.
async getMessages(room, limit = 100) { async getMessages(room, limit = 100) {
return this.state.messages return this.state.messages
.filter((message) => message.room === room) .filter((message) => message.room === room)
.slice(-limit) .slice(-limit)
} }
// roomMap: Objekt das pro Room die Teilnehmerzahl zählt — schneller als mehrere filter()-Aufrufe.
// localeCompare: Alphabetische Sortierung mit Sprachunterstützung (Umlaute etc.).
async getRooms() {
const roomMap = {}
for (const user of this.state.users) {
if (!roomMap[user.room]) {
roomMap[user.room] = { name: user.room, participantCount: 0 }
}
if (user.isOnline) roomMap[user.room].participantCount++
}
return Object.values(roomMap).sort((a, b) => a.name.localeCompare(b.name))
}
} }
module.exports = JsonStore module.exports = JsonStore
+57 -10
View File
@@ -1,6 +1,7 @@
const config = require('../../config/database') const config = require('../../config/database')
// Sicheres Laden des MongoDB-Treibers — wenn das Paket nicht installiert ist,
// wird der Fehler erst in createMongoStore() mit einer klaren Meldung geworfen.
let MongoClient let MongoClient
try { try {
;({ MongoClient } = require('mongodb')) ;({ MongoClient } = require('mongodb'))
@@ -8,12 +9,14 @@ try {
MongoClient = null MongoClient = null
} }
// Collection-Namen als Konstanten — wenn sich ein Name ändert, muss er nur hier angepasst werden.
const collectionNames = { const collectionNames = {
users: 'users', users: 'users',
messages: 'messages', messages: 'messages',
} }
// MongoDB speichert den Primärschlüssel intern als "_id".
// Diese Funktion übersetzt das in "id" damit der Rest der App nichts von MongoDB-Interna wissen muss.
function normalizeUser(doc) { function normalizeUser(doc) {
if (!doc) return null if (!doc) return null
const { const {
@@ -34,6 +37,8 @@ function normalizeUser(doc) {
} }
} }
// Gleiche Logik wie normalizeUser — username wird redundant in der Nachricht gespeichert
// damit beim Laden keine extra DB-Abfrage für den Usernamen nötig ist.
function normalizeMessage(doc) { function normalizeMessage(doc) {
if (!doc) return null if (!doc) return null
const { const {
@@ -54,6 +59,8 @@ function normalizeMessage(doc) {
} }
} }
// Kompatibilitätsfunktion für ältere MongoDB-Treiber (< 4.0).
// findOneAndUpdate gibt dort das Ergebnis in einem "value"-Wrapper zurück — ab 4.0 direkt.
function extractDocument(result) { function extractDocument(result) {
if (!result) return null if (!result) return null
return Object.prototype.hasOwnProperty.call(result, 'value') return Object.prototype.hasOwnProperty.call(result, 'value')
@@ -61,8 +68,9 @@ function extractDocument(result) {
: result : result
} }
// async damit der Start der App nicht blockiert während MongoDB-Verbindung und Indizes aufgebaut werden.
async function createMongoStore() { async function createMongoStore() {
// Guard: fail fast with an actionable message if the driver is missing.
if (!MongoClient) { if (!MongoClient) {
const message = const message =
'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.' 'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.'
@@ -73,18 +81,20 @@ async function createMongoStore() {
const client = new MongoClient(config.uri, { const client = new MongoClient(config.uri, {
...config.options, ...config.options,
appName: 'chat-app', appName: 'chat-app', // Wird in MongoDB Atlas angezeigt um zu sehen welche App verbunden ist.
}) })
await client.connect() await client.connect()
const db = client.db(config.dbName) const db = client.db(config.dbName) // Entspricht "USE chatapp" in SQL.
const users = db.collection(collectionNames.users) const users = db.collection(collectionNames.users)
const messages = db.collection(collectionNames.messages) const messages = db.collection(collectionNames.messages)
// Indizes werden einmalig beim Start angelegt — ohne Index durchsucht MongoDB jede Abfrage
// alle Dokumente (Full Collection Scan). Promise.all legt beide parallel an.
await Promise.all([ await Promise.all([
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), // Für getParticipants()
messages.createIndex({ room: 1, createdAt: 1 }), messages.createIndex({ room: 1, createdAt: 1 }), // Für getMessages()
]) ])
console.log( console.log(
@@ -93,7 +103,12 @@ async function createMongoStore() {
}/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`, }/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`,
) )
// Öffentliches Interface des Stores — der Rest der App kennt keine MongoDB-Details (Repository-Pattern).
return { return {
// Upsert = Update + Insert in einem Schritt (atomare Operation).
// Wenn User existiert: Felder aktualisieren. Wenn nicht: neu anlegen.
// $setOnInsert stellt sicher dass joinedAt nur beim ersten Anlegen gesetzt wird, nicht bei Updates.
async upsertUser({ id, username, room }) { async upsertUser({ id, username, room }) {
const now = new Date() const now = new Date()
const result = await users.findOneAndUpdate( const result = await users.findOneAndUpdate(
@@ -112,7 +127,7 @@ async function createMongoStore() {
}, },
{ {
upsert: true, upsert: true,
returnDocument: 'after', returnDocument: 'after', // Aktualisiertes Dokument zurückgeben, nicht das alte.
}, },
) )
const document = extractDocument(result) const document = extractDocument(result)
@@ -120,11 +135,12 @@ async function createMongoStore() {
return normalizeUser(document) return normalizeUser(document)
} }
// Fallback: Manche MongoDB-Versionen geben bei einem Upsert-Insert kein Dokument zurück.
const inserted = await users.findOne({ _id: id }) const inserted = await users.findOne({ _id: id })
return normalizeUser(inserted) return normalizeUser(inserted)
}, },
// $set aktualisiert nur die angegebenen Felder — der Rest (z.B. username) bleibt unberührt.
async setUserOnline(userId, room) { async setUserOnline(userId, room) {
const result = await users.findOneAndUpdate( const result = await users.findOneAndUpdate(
{ _id: userId }, { _id: userId },
@@ -140,6 +156,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result)) return normalizeUser(extractDocument(result))
}, },
// lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
async setUserOffline(userId) { async setUserOffline(userId) {
const result = await users.findOneAndUpdate( const result = await users.findOneAndUpdate(
{ _id: userId }, { _id: userId },
@@ -154,6 +171,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result)) return normalizeUser(extractDocument(result))
}, },
// trim() auf Datenbankebene — auch wenn die Frontend-Validierung fehlt, landen keine schmutzigen Daten in der DB.
async updateUsername(userId, username) { async updateUsername(userId, username) {
const result = await users.findOneAndUpdate( const result = await users.findOneAndUpdate(
{ _id: userId }, { _id: userId },
@@ -168,6 +186,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result)) return normalizeUser(extractDocument(result))
}, },
// findOneAndDelete löscht und gibt das Dokument in einem Schritt zurück — kein extra findOne() nötig.
async removeUser(userId) { async removeUser(userId) {
const result = await users.findOneAndDelete({ _id: userId }) const result = await users.findOneAndDelete({ _id: userId })
return normalizeUser(extractDocument(result)) return normalizeUser(extractDocument(result))
@@ -178,14 +197,19 @@ async function createMongoStore() {
return normalizeUser(doc) return normalizeUser(doc)
}, },
// Online-User zuerst, dann nach letzter Aktivität sortiert.
// toArray() lädt alle Cursor-Ergebnisse in den Speicher — bei sehr großen Datenmengen wäre ein Cursor besser.
async getParticipants(room) { async getParticipants(room) {
const docs = await users const docs = await users
.find({ room }) .find({ room })
.sort({ isOnline: -1, lastActiveAt: -1 }) .sort({ isOnline: -1, lastActiveAt: -1 }) // -1 = absteigend, 1 = aufsteigend
.toArray() .toArray()
return docs.map(normalizeUser) return docs.map(normalizeUser)
}, },
// _id wird von außen als UUID übergeben statt von MongoDB generiert —
// so ist die ID bereits bekannt bevor die DB antwortet (z.B. für den WebSocket-Broadcast).
// createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
async addMessage({ id, room, userId, username, text }) { async addMessage({ id, room, userId, username, text }) {
const message = { const message = {
_id: id, _id: id,
@@ -196,9 +220,11 @@ async function createMongoStore() {
createdAt: new Date(), createdAt: new Date(),
} }
await messages.insertOne(message) await messages.insertOne(message)
// Lokales Objekt zurückgeben statt nochmal aus der DB lesen — spart einen Query.
return normalizeMessage(message) return normalizeMessage(message)
}, },
// limit = 100 verhindert dass bei einem langen Chatverlauf alles auf einmal geladen wird.
async getMessages(room, limit = 100) { async getMessages(room, limit = 100) {
const docs = await messages const docs = await messages
.find({ room }) .find({ room })
@@ -208,6 +234,27 @@ async function createMongoStore() {
return docs.map(normalizeMessage) return docs.map(normalizeMessage)
}, },
// Aggregation Pipeline: MongoDB berechnet das Ergebnis serverseitig — effizienter als
// alle Daten laden und in JavaScript auswerten.
// $group: Gruppiert User nach Room (wie GROUP BY in SQL).
// $cond: Zählt nur Online-User pro Room.
// $project: Blendet _id aus und benennt es in "name" um.
async getRooms() {
const result = await users.aggregate([
{
$group: {
_id: '$room',
participantCount: { $sum: { $cond: ['$isOnline', 1, 0] } },
},
},
{ $project: { _id: 0, name: '$_id', participantCount: 1 } },
{ $sort: { name: 1 } },
]).toArray()
return result
},
// Verbindung sauber trennen beim Herunterfahren der App — ohne close() bleiben
// offene Verbindungen auf dem MongoDB-Server hängen.
async disconnect() { async disconnect() {
await client.close() await client.close()
}, },
+8
View File
@@ -5,6 +5,14 @@ const asyncHandler = require('../utils/asyncHandler')
function createRoomsRouter({ database, io }) { function createRoomsRouter({ database, io }) {
const router = express.Router() const router = express.Router()
router.get(
'/',
asyncHandler(async (_req, res) => {
const rooms = await database.getRooms()
res.json({ rooms })
}),
)
router.post( router.post(
'/:room/join', '/:room/join',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
+49
View File
@@ -0,0 +1,49 @@
## Docker Setup
Run the full stack with:
```bash
docker compose up --build
```
Services:
- Frontend: `http://localhost:5173`
- Backend: `http://localhost:4000`
- MongoDB: `mongodb://localhost:27017/chatapp`
The Compose file starts all required services together:
- `frontend` as a Vite container
- `backend` as a Node/Express container
- `mongo` with a persistent `mongo-data` volume
Stop everything with:
```bash
docker compose down
```
Remove containers and database volume with:
```bash
docker compose down -v
```
## Local Development Without Docker
1. Copy the backend environment file:
```bash
cp Backend/.env.example Backend/.env
```
2. Start MongoDB locally or with Docker.
3. Start the backend:
```bash
cd Backend && npm run dev
```
The backend tries MongoDB at `mongodb://127.0.0.1:27017/chatapp` first and falls back to `Backend/data/db.json` only if MongoDB is unavailable. Use `DATA_STORE=mongo` to fail fast on MongoDB errors, or `DATA_STORE=json` to force the JSON store.
+93 -54
View File
@@ -1,59 +1,98 @@
services:
frontend:
image: francesco448/chat-app-frontend:latest
build:
context: ./frontend
args:
VITE_API_URL: ""
restart: unless-stopped
depends_on:
- backend
ports:
- "80:80"
networks:
- chat-network
backend:
image: francesco448/chat-app-backend:latest
build:
context: ./Backend
restart: unless-stopped
depends_on:
mongo:
condition: service_healthy
environment:
- PORT=4000
- CLIENT_ORIGIN=http://localhost
- DATA_STORE=mongo
- MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:-chatpass}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
- MONGO_DB=${MONGO_DB:-chatapp}
networks:
- chat-network
mongo:
image: mongo:7
container_name: chat-app-mongo
restart: unless-stopped
environment:
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:-chatpass}
- MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
volumes:
- mongo-data:/data/db
ports:
- "27017:27017"
networks:
- chat-network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
networks: networks:
chat-network: chat-network:
driver: bridge driver: bridge
services:
frontend:
# Fertiges Frontend-Image von Docker Hub.
image: francesco448/chat-app-frontend:latest
container_name: chat-app-frontend
restart: unless-stopped
# Frontend startet erst, nachdem der Backend-Container gestartet wurde.
depends_on:
- backend
# Nur das Frontend wird nach außen veröffentlicht.
# Standardmäßig TrueNAS-Port 9004 -> Nginx-Port 80.
# FRONTEND_PORT kann optional über .env geändert werden.
ports:
- "${FRONTEND_PORT:-9004}:80"
networks:
- chat-network
backend:
image: francesco448/chat-app-backend:latest
container_name: chat-app-backend
restart: unless-stopped
depends_on:
mongo:
condition: service_healthy
environment:
- PORT=4000
- NODE_ENV=production
- CLIENT_ORIGIN=${CLIENT_ORIGIN:?CLIENT_ORIGIN muss gesetzt werden}
- DATA_STORE=mongo
- MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss gesetzt werden}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
- MONGO_DB=${MONGO_DB:-chatapp}
networks:
- chat-network
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://localhost:4000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
mongo:
# Feste Major-Version verwenden statt "latest".
image: mongo:7
container_name: chat-app-mongo
restart: unless-stopped
environment:
# Benutzer kann über .env überschrieben werden.
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
# Für Production kein unsicheres Default-Passwort wie "chatpass".
# Der Stack startet nicht, solange das Passwort in .env fehlt.
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss in .env gesetzt werden}
# Datenbankname kann über .env überschrieben werden.
- MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
# Persistenz direkt auf deinem TrueNAS-Dataset.
volumes: volumes:
mongo-data: - /mnt/PoolHDD/AppData/Chat-App/mongodb:/data/db
driver: local
# Kein Port 27017 nach außen.
# MongoDB ist ausschließlich im internen Docker-Netzwerk erreichbar.
networks:
- chat-network
# Authentifizierter MongoDB-Healthcheck.
# $$ verhindert, dass Docker Compose die Variablen bereits auf dem Host ersetzt.
healthcheck:
test:
- CMD-SHELL
- >-
mongosh --quiet
--host 127.0.0.1
--username "$$MONGO_INITDB_ROOT_USERNAME"
--password "$$MONGO_INITDB_ROOT_PASSWORD"
--authenticationDatabase admin
--eval "quit(db.adminCommand('ping').ok ? 0 : 2)"
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
+25 -1
View File
@@ -1,23 +1,47 @@
# Multi-Stage Build: Zwei separate Phasen in einem Dockerfile.
# Vorteil: Der finale Container enthält nur das Minimum — kein Node.js, kein Source-Code, keine node_modules.
# Kleineres und sichereres Image als wenn alles in einem Stage wäre.
# Stage 1: Builder — zuständig für das Bauen der React-App.
# "AS builder" gibt diesem Stage einen Namen damit Stage 2 darauf zugreifen kann.
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
# Arbeitsverzeichnis setzen — alle folgenden Befehle laufen hier.
WORKDIR /app WORKDIR /app
# Zuerst nur package.json kopieren für optimales Layer Caching.
# npm ci läuft nur neu wenn sich die Dependencies ändern, nicht bei jedem Code-Change.
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
# Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
COPY . . COPY . .
# VITE_API_URL als Build-Argument — wird von docker-compose.yml übergeben.
# ARG ist nur während des Builds verfügbar, ENV macht sie für Vite lesbar.
# Warum beide: Vite läuft als Node.js-Prozess und liest ENV-Variablen — nicht ARG direkt.
# Leerer Default "" bedeutet relative URLs werden verwendet — Nginx leitet intern weiter.
ARG VITE_API_URL="" ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
# Vite baut die React-App in den /app/dist Ordner — optimiertes, minifiziertes Bundle.
RUN npm run build RUN npm run build
# Stage 2: Nginx — zuständig für das Ausliefern der gebauten Dateien.
# Node.js aus Stage 1 wird komplett weggeworfen — nur der dist-Ordner wird übernommen.
FROM nginx:alpine FROM nginx:alpine
# Nur den fertigen dist-Ordner aus Stage 1 kopieren — kein Source-Code, kein Node.js.
# --from=builder verweist auf Stage 1 per Name.
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Eigene Nginx-Konfiguration kopieren — überschreibt die Standard-Konfiguration.
# Enthält Reverse Proxy Regeln für /rooms und /socket.io sowie SPA-Fallback.
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
# Port 80 dokumentieren — Standard HTTP-Port.
EXPOSE 80 EXPOSE 80
# Nginx im Vordergrund starten — ohne "daemon off" würde Nginx in den Hintergrund gehen
# und Docker würde denken der Container ist fertig und ihn stoppen.
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+28 -5
View File
@@ -1,37 +1,60 @@
server { server {
# Nginx lauscht auf Port 80 (Standard HTTP-Port).
# Der Browser spricht Nginx an — Nginx entscheidet dann wohin die Anfrage geht (Reverse Proxy).
listen 80; listen 80;
# Serve the static React build # Statische React-Dateien ausliefern (HTML, CSS, JS).
# Vite baut die App in einen dist-Ordner — dieser wird beim Docker-Build nach /usr/share/nginx/html kopiert.
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Fallback to index.html for client-side routing
# try_files: Nginx sucht zuerst nach einer echten Datei ($uri),
# dann nach einem Ordner ($uri/), und fällt schliesslich auf index.html zurück.
# Warum nötig: React ist eine Single Page Application (SPA) — alle Routen
# existieren nur im Browser, nicht als echte Dateien auf dem Server.
# Ohne diesen Fallback würde ein direkter Aufruf von z.B. /chat einen 404 liefern.
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Proxy REST API requests to the backend # Reverse Proxy: Alle Anfragen an /rooms werden an das Backend weitergeleitet.
# Warum Reverse Proxy statt direkter Backend-URL: Der Browser kennt nur Nginx auf Port 80.
# Das Backend ist im Docker-Netzwerk unter dem Hostnamen "backend" erreichbar — nicht von aussen.
# Vorteil: Backend muss keinen öffentlichen Port exponieren, alles läuft über Nginx.
location /rooms { location /rooms {
proxy_pass http://backend:4000; proxy_pass http://backend:4000;
proxy_http_version 1.1; proxy_http_version 1.1;
# Host-Header weiterleiten damit das Backend weiss von welcher Domain die Anfrage kommt.
proxy_set_header Host $host; proxy_set_header Host $host;
# X-Real-IP: Echte IP-Adresse des Clients weitergeben — sonst sieht das Backend nur Nginx als Absender.
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
# X-Forwarded-For: Liste aller Proxys durch die die Anfrage gelaufen ist (Audit Trail).
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# Proxy the health check endpoint to the backend # Health-Check-Endpoint wird ebenfalls ans Backend weitergeleitet.
# Wird von Docker genutzt um zu prüfen ob das Backend läuft (siehe healthcheck in docker-compose.yml).
location /health { location /health {
proxy_pass http://backend:4000; proxy_pass http://backend:4000;
proxy_http_version 1.1; proxy_http_version 1.1;
} }
# Proxy Socket.IO — requires WebSocket upgrade headers # WebSocket-Proxy für Socket.IO — braucht spezielle Upgrade-Header.
# HTTP und WebSocket sind unterschiedliche Protokolle. Ein WebSocket startet als HTTP-Anfrage
# und wird dann per "Upgrade" auf eine dauerhafte bidirektionale Verbindung umgestellt.
# Ohne diese Header würde Nginx die Verbindung als normale HTTP-Anfrage behandeln
# und der WebSocket-Handshake würde fehlschlagen.
location /socket.io/ { location /socket.io/ {
proxy_pass http://backend:4000; proxy_pass http://backend:4000;
proxy_http_version 1.1; proxy_http_version 1.1;
# Siehe Zeile 72 in App.jsx
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
# proxy_cache_bypass: Verhindert dass Nginx WebSocket-Verbindungen cached —
# eine dauerhafte Verbindung darf nicht gecacht werden.
# da websocket keine fixe Antwort gibt und es ein laufender Kommunikationskanal ist, kann man es nicht sinnvoll cachen
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
} }
+59
View File
@@ -131,6 +131,65 @@ button:disabled {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.rooms-list {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.15);
}
.rooms-list__label {
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 0.5rem;
}
.rooms-list__chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.room-chip {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 999px;
padding: 0.3rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.room-chip:hover {
border-color: rgba(94, 234, 212, 0.6);
}
.room-chip--active {
border-color: rgba(94, 234, 212, 0.8);
color: #5eead4;
}
.room-chip__count {
background: rgba(148, 163, 184, 0.2);
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-size: 0.75rem;
}
.typing-indicator {
padding: 0.4rem 1.5rem;
font-size: 0.82rem;
color: var(--muted);
font-style: italic;
min-height: 1.6rem;
margin: 0;
}
/* Centralised error style classes */ /* Centralised error style classes */
/* Inline text error (e.g. below a field) */ /* Inline text error (e.g. below a field) */
+164 -16
View File
@@ -5,32 +5,47 @@ import { ERROR_MESSAGES } from './constants/errorMessages'
import { validateJoinForm } from './utils/validators' import { validateJoinForm } from './utils/validators'
import './App.css' import './App.css'
// VITE_API_URL wird beim Build von Vite eingebettet — so muss die URL nicht hardcoded stehen.
// ?? (Nullish Coalescing): Fallback auf localhost:4000 nur wenn die Variable nicht gesetzt ist.
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
function App() { function App() {
// t('key') gibt den übersetzten Text aus der aktiven Sprachdatei zurück (z.B. de/translation.json).
const { t } = useTranslation() const { t } = useTranslation()
// Jede State-Variable ist einzeln deklariert statt in einem Objekt.
// So rendert React nur neu wenn sich der jeweilige Wert ändert — nicht bei jeder State-Änderung.
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [room, setRoom] = useState('general') const [room, setRoom] = useState('general') // Standardroom beim ersten Laden
const [session, setSession] = useState(null) const [session, setSession] = useState(null) // null = nicht eingeloggt; sonst { id, username, room }
const [status, setStatus] = useState('idle') const [status, setStatus] = useState('idle') // idle | connecting | connected | reconnecting | error
const [messages, setMessages] = useState([]) const [messages, setMessages] = useState([])
const [participants, setParticipants] = useState([]) const [participants, setParticipants] = useState([])
const [messageInput, setMessageInput] = useState('') const [messageInput, setMessageInput] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [typingUsers, setTypingUsers] = useState([]) // Usernamen der User die gerade tippen
const [rooms, setRooms] = useState([]) // Alle aktiven Rooms für die Chip-Auswahl
const socketRef = useRef(null) // useRef speichert Werte ohne ein Re-Render auszulösen — ideal für Socket, DOM-Elemente und Timer.
const messagesEndRef = useRef(null) const socketRef = useRef(null) // Aktive Socket.IO-Verbindung
const messagesEndRef = useRef(null) // Leeres div am Ende der Nachrichtenliste für Auto-Scroll
const typingTimerRef = useRef(null) // Timer-ID für den typing:stop Debounce
// Abgeleitete Werte direkt aus State berechnet — kein eigener useState nötig.
const connectionLabel = t(`status.${status}`, { defaultValue: status }) const connectionLabel = t(`status.${status}`, { defaultValue: status })
const isConnected = status === 'connected' const isConnected = status === 'connected'
// Dieser Effekt verwaltet den gesamten WebSocket-Lebenszyklus.
// Dependency-Array [session?.id, session?.room]: Effekt läuft neu wenn User oder Room wechselt.
useEffect(() => { useEffect(() => {
// Kein Session → Verbindung trennen. Die Cleanup-Funktion am Ende erledigt das.
if (!session?.id || !session?.room) { if (!session?.id || !session?.room) {
setStatus('idle') setStatus('idle')
return return
} }
// isCancelled verhindert State-Updates nach dem Unmount der Komponente (Memory Leak).
let isCancelled = false let isCancelled = false
let retryCount = 0 let retryCount = 0
let reconnectTimer let reconnectTimer
@@ -38,6 +53,11 @@ function App() {
const connect = () => { const connect = () => {
if (isCancelled) return if (isCancelled) return
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting') setStatus(retryCount === 0 ? 'connecting' : 'reconnecting')
// auth wird beim WebSocket-Handshake mitgeschickt — der Server prüft ob userId in der DB existiert
// und ob sie zum angegebenen Room gehört. Wer vorher nicht POST /join aufgerufen hat,
// hat keine gültige userId und wird mit "User not authorized for this room" abgelehnt.
// transports: ['websocket'] erzwingt reines WebSocket, kein HTTP-Polling als Fallback.
const socket = io(API_URL, { const socket = io(API_URL, {
auth: { auth: {
room: session.room, room: session.room,
@@ -46,6 +66,8 @@ function App() {
reconnectionAttempts: 5, reconnectionAttempts: 5,
transports: ['websocket'], transports: ['websocket'],
}) })
// Socket in Ref speichern damit handleSendMessage und andere Handler darauf zugreifen können.
socketRef.current = socket socketRef.current = socket
socket.on('connect', () => { socket.on('connect', () => {
@@ -53,16 +75,15 @@ function App() {
setStatus('connected') setStatus('connected')
}) })
// socket.io ist der Manager (Verbindungsebene), socket der Namespace (Anwendungsebene).
// Reconnect-Events kommen vom Manager, nicht vom Socket selbst.
socket.io.on('reconnect_attempt', () => { socket.io.on('reconnect_attempt', () => {
if (!isCancelled) { if (!isCancelled) setStatus('reconnecting')
setStatus('reconnecting')
}
}) })
// Alle 5 Reconnect-Versuche gescheitert — User muss manuell neu verbinden.
socket.io.on('reconnect_failed', () => { socket.io.on('reconnect_failed', () => {
if (!isCancelled) { if (!isCancelled) setStatus('error')
setStatus('error')
}
}) })
socket.on('connect_error', (socketError) => { socket.on('connect_error', (socketError) => {
@@ -73,6 +94,9 @@ function App() {
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
if (isCancelled) return if (isCancelled) return
setTypingUsers([])
// 'io client disconnect': Der Client hat selbst getrennt (handleLeave) → kein Reconnect.
// Bei allen anderen Gründen (Netzwerkfehler, Server-Neustart) → erneut versuchen.
if (reason !== 'io client disconnect' && retryCount < 5) { if (reason !== 'io client disconnect' && retryCount < 5) {
retryCount += 1 retryCount += 1
setStatus('reconnecting') setStatus('reconnecting')
@@ -81,25 +105,36 @@ function App() {
} }
}) })
// Server schickt beim Verbinden die letzten N Nachrichten des Rooms.
socket.on('history:init', (payload) => { socket.on('history:init', (payload) => {
setMessages(Array.isArray(payload) ? payload : []) setMessages(Array.isArray(payload) ? payload : [])
}) })
// [...prev, payload] erstellt ein neues Array — React erkennt Änderungen nur per Referenzvergleich.
// prev.push() würde dieselbe Referenz behalten → kein Re-Render.
socket.on('message:new', (payload) => { socket.on('message:new', (payload) => {
if (payload) { if (payload) {
setMessages((prev) => [...prev, payload]) setMessages((prev) => [...prev, payload])
} }
}) })
// Wird bei jedem Join/Leave eines Users im Room gesendet.
socket.on('participants:update', (payload) => { socket.on('participants:update', (payload) => {
setParticipants(Array.isArray(payload) ? payload : []) setParticipants(Array.isArray(payload) ? payload : [])
}) })
// Eigener User wird herausgefiltert — man sieht sich selbst nicht als "tippt gerade".
socket.on('typing:update', (payload) => {
if (!Array.isArray(payload)) return
setTypingUsers(payload.filter((u) => u.userId !== session.id).map((u) => u.username))
})
socket.on('connection:ready', () => { socket.on('connection:ready', () => {
setError('') setError('')
}) })
} }
// setTimeout 0ms: Gibt React Zeit das Rendering abzuschliessen bevor der Status gesetzt wird.
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
if (!socketRef.current?.connected && !isCancelled) { if (!socketRef.current?.connected && !isCancelled) {
setStatus('connecting') setStatus('connecting')
@@ -108,22 +143,55 @@ function App() {
connect() connect()
// Cleanup: Wird ausgeführt wenn die Komponente unmountet oder der Effekt neu startet.
// Ohne Cleanup würden mehrere Sockets gleichzeitig aktiv sein → Memory Leak.
return () => { return () => {
isCancelled = true isCancelled = true
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
clearTimeout(typingTimerRef.current)
socketRef.current?.disconnect() socketRef.current?.disconnect()
socketRef.current = null socketRef.current = null
setTypingUsers([])
} }
}, [session?.id, session?.room]) }, [session?.id, session?.room])
// Scrollt nach jeder neuen Nachricht ans Ende der Liste — smooth für sanfte Animation.
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [messages])
// Leeres Dependency-Array []: Läuft nur einmal beim ersten Laden der Seite.
useEffect(() => {
fetchRooms()
}, [])
const fetchRooms = async () => {
try {
const res = await fetch(`${API_URL}/rooms`)
if (res.ok) {
const { rooms: list } = await res.json()
setRooms(list)
}
} catch {
// Rooms sind nicht kritisch — App funktioniert auch ohne die Chip-Navigation.
}
}
const participantCount = participants.length const participantCount = participants.length
// useMemo: Text wird nur neu berechnet wenn sich typingUsers ändert — nicht bei jedem Render.
const typingLabel = useMemo(() => {
if (typingUsers.length === 0) return ''
if (typingUsers.length === 1) return `${typingUsers[0]} schreibt…`
if (typingUsers.length === 2) return `${typingUsers[0]} und ${typingUsers[1]} schreiben…`
return `${typingUsers.length} Personen schreiben…`
}, [typingUsers])
const handleJoin = async (event) => { const handleJoin = async (event) => {
// Verhindert das Standard-Browserverhalten: Seite neu laden beim Formular-Submit.
event.preventDefault() event.preventDefault()
// Client-seitige Validierung vor dem API-Call — spart einen unnötigen Netzwerk-Request.
const validationError = validateJoinForm(username, room) const validationError = validateJoinForm(username, room)
if (validationError) { if (validationError) {
setError(validationError) setError(validationError)
@@ -133,12 +201,14 @@ function App() {
setError('') setError('')
setStatus(session ? 'reconnecting' : 'connecting') setStatus(session ? 'reconnecting' : 'connecting')
try { try {
// encodeURIComponent kodiert Sonderzeichen im Room-Namen — z.B. Leerzeichen → %20.
// Ohne Kodierung wäre die URL ungültig und der Request würde scheitern.
const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, { const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
username: username.trim(), username: username.trim(),
userId: session?.id, userId: session?.id, // Bei Room-Wechsel: bestehende userId mitschicken statt neue zu erstellen.
}), }),
}) })
@@ -150,10 +220,19 @@ function App() {
if (!data?.user) { if (!data?.user) {
throw new Error(ERROR_MESSAGES.api.noUserReturned) throw new Error(ERROR_MESSAGES.api.noUserReturned)
} }
// setSession ändert session?.id → WebSocket-useEffect läuft neu → baut neue Verbindung auf.
setSession(data.user) setSession(data.user)
setUsername(data.user.username) setUsername(data.user.username)
setRoom(data.user.room) setRoom(data.user.room)
// Snapshot per HTTP laden bevor der WebSocket verbunden ist — UI ist nicht leer.
// Der WebSocket überschreibt den Snapshot später mit history:init.
await fetchRoomSnapshots(data.user.room) await fetchRoomSnapshots(data.user.room)
fetchRooms()
if (socketRef.current?.connected) {
setStatus('connected')
}
} catch (joinError) { } catch (joinError) {
console.error(joinError) console.error(joinError)
setError(joinError.message) setError(joinError.message)
@@ -161,6 +240,7 @@ function App() {
} }
} }
// Promise.all führt beide Requests parallel aus — Gesamtdauer = max(t1, t2) statt t1 + t2.
const fetchRoomSnapshots = async (targetRoom) => { const fetchRoomSnapshots = async (targetRoom) => {
const encodedRoom = encodeURIComponent(targetRoom) const encodedRoom = encodeURIComponent(targetRoom)
try { try {
@@ -177,20 +257,38 @@ function App() {
setParticipants(list) setParticipants(list)
} }
} catch (snapshotError) { } catch (snapshotError) {
// Nicht kritisch — WebSocket liefert history:init und participants:update nach.
console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError) console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError)
} }
} }
// Debounce-Muster: typing:start sofort senden, typing:stop erst nach 1500ms Pause.
// clearTimeout vor jedem neuen Timeout — Timer wird bei jedem Tastendruck zurückgesetzt.
const handleMessageInputChange = (event) => {
setMessageInput(event.target.value)
const socket = socketRef.current
if (!socket?.connected) return
socket.emit('typing:start')
clearTimeout(typingTimerRef.current)
typingTimerRef.current = setTimeout(() => {
socket.emit('typing:stop')
}, 1500)
}
// Nachricht per WebSocket senden — nicht per HTTP.
// Vorteil: Keine neue Verbindung pro Nachricht, Server broadcastet direkt an alle im Room.
// Datenfluss: emit('message:send') → Server speichert → Server sendet 'message:new' an alle.
const handleSendMessage = (event) => { const handleSendMessage = (event) => {
event.preventDefault() event.preventDefault()
if (!session || !messageInput.trim()) { if (!session || !messageInput.trim()) return
return
}
const socket = socketRef.current const socket = socketRef.current
if (!socket || !socket.connected) { if (!socket || !socket.connected) {
setError(ERROR_MESSAGES.connection.noActiveConnection) setError(ERROR_MESSAGES.connection.noActiveConnection)
return return
} }
// Tipp-Indikator sofort beenden — nicht auf die 1500ms warten.
clearTimeout(typingTimerRef.current)
socket.emit('typing:stop')
try { try {
socket.emit('message:send', { socket.emit('message:send', {
text: messageInput.trim(), text: messageInput.trim(),
@@ -203,8 +301,12 @@ function App() {
} }
} }
// finally läuft immer — egal ob HTTP-Request erfolgreich war oder nicht.
// setSession(null) löst den WebSocket-useEffect aus → Verbindung wird sauber getrennt.
const handleLeave = async () => { const handleLeave = async () => {
if (!session) return if (!session) return
clearTimeout(typingTimerRef.current)
socketRef.current?.emit('typing:stop')
try { try {
await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, { await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, {
method: 'POST', method: 'POST',
@@ -212,15 +314,19 @@ function App() {
body: JSON.stringify({ userId: session.id }), body: JSON.stringify({ userId: session.id }),
}) })
} catch (leaveError) { } catch (leaveError) {
// HTTP-Fehler ignorieren — lokaler State wird in finally trotzdem zurückgesetzt.
console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError) console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError)
} finally { } finally {
setSession(null) setSession(null)
setMessages([]) setMessages([])
setParticipants([]) setParticipants([])
setTypingUsers([])
setStatus('idle') setStatus('idle')
fetchRooms()
} }
} }
// useMemo: Kontextsensitiver Hilfstext — wird nur neu berechnet wenn session, status oder Sprache wechselt.
const connectionHint = useMemo(() => { const connectionHint = useMemo(() => {
if (!session) return t('hint.noSession') if (!session) return t('hint.noSession')
switch (status) { switch (status) {
@@ -239,6 +345,7 @@ function App() {
<h1>{t('app.headline')}</h1> <h1>{t('app.headline')}</h1>
<p>{connectionHint}</p> <p>{connectionHint}</p>
</div> </div>
{/* CSS-Klasse wird dynamisch gesetzt: status-connected, status-error etc. */}
<div className={`status-badge status-${status}`}> <div className={`status-badge status-${status}`}>
<span className="status-dot" aria-hidden="true"></span> <span className="status-dot" aria-hidden="true"></span>
{connectionLabel} {connectionLabel}
@@ -249,6 +356,7 @@ function App() {
<form className="session-form" onSubmit={handleJoin}> <form className="session-form" onSubmit={handleJoin}>
<div className="field-group"> <div className="field-group">
<label htmlFor="username">{t('form.username')}</label> <label htmlFor="username">{t('form.username')}</label>
{/* Controlled Input: value kommt aus State, onChange aktualisiert State. */}
<input <input
id="username" id="username"
value={username} value={username}
@@ -263,12 +371,15 @@ function App() {
value={room} value={room}
placeholder={t('form.roomPlaceholder')} placeholder={t('form.roomPlaceholder')}
onChange={(event) => setRoom(event.target.value.toLowerCase())} onChange={(event) => setRoom(event.target.value.toLowerCase())}
// toLowerCase() verhindert doppelte Rooms durch unterschiedliche Schreibweise ("General" vs "general").
/> />
</div> </div>
<div className="actions"> <div className="actions">
<button type="submit"> <button type="submit">
{/* Button-Text wechselt: "Verbinden" ohne Session, "Aktualisieren" wenn bereits eingeloggt. */}
{session ? t('form.update') : t('form.connect')} {session ? t('form.update') : t('form.connect')}
</button> </button>
{/* Leave-Button nur anzeigen wenn eine Session aktiv ist (Conditional Rendering). */}
{session ? ( {session ? (
<button type="button" className="ghost" onClick={handleLeave}> <button type="button" className="ghost" onClick={handleLeave}>
{t('form.leave')} {t('form.leave')}
@@ -277,6 +388,25 @@ function App() {
</div> </div>
</form> </form>
{error ? <p className="form-error">{t(error)}</p> : null} {error ? <p className="form-error">{t(error)}</p> : null}
{rooms.length > 0 && (
<div className="rooms-list">
<p className="rooms-list__label">{t('rooms.title')}</p>
<div className="rooms-list__chips">
{rooms.map((r) => (
<button
key={r.name}
// key ist Pflicht bei React-Listen React nutzt es beim Re-Render um Elemente zu identifizieren.
type="button"
className={`room-chip ${session?.room === r.name ? 'room-chip--active' : ''}`}
onClick={() => setRoom(r.name)}
>
#{r.name}
<span className="room-chip__count">{r.participantCount}</span>
</button>
))}
</div>
</div>
)}
</section> </section>
<main className="chat-grid"> <main className="chat-grid">
@@ -284,6 +414,7 @@ function App() {
<header className="chat-panel__header"> <header className="chat-panel__header">
<div> <div>
<h2>{t('chat.title')}</h2> <h2>{t('chat.title')}</h2>
{/* ?? zeigt session?.room wenn vorhanden, sonst den Wert aus dem Input-Feld. */}
<p>{t('chat.liveFeed', { room: session?.room ?? room })}</p> <p>{t('chat.liveFeed', { room: session?.room ?? room })}</p>
</div> </div>
<span className="muted">{t('chat.messageCount', { count: messages.length })}</span> <span className="muted">{t('chat.messageCount', { count: messages.length })}</span>
@@ -296,6 +427,7 @@ function App() {
<article <article
key={message.id} key={message.id}
className={`message ${message.userId === session?.id ? 'me' : ''}`} className={`message ${message.userId === session?.id ? 'me' : ''}`}
// CSS-Klasse 'me': Eigene Nachrichten werden rechts ausgerichtet (Chat-Bubble-Stil).
> >
<header> <header>
<strong>{message.username}</strong> <strong>{message.username}</strong>
@@ -305,23 +437,31 @@ function App() {
</article> </article>
)) ))
)} )}
{/* Leeres div als Scroll-Anker — scrollIntoView() hier = ans Ende scrollen. */}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Tipp-Indikator nur anzeigen wenn typingLabel nicht leer ist. */}
{typingLabel && (
<p className="typing-indicator">{typingLabel}</p>
)}
<form className="composer" onSubmit={handleSendMessage}> <form className="composer" onSubmit={handleSendMessage}>
<input <input
value={messageInput} value={messageInput}
onChange={(event) => setMessageInput(event.target.value)} onChange={handleMessageInputChange}
placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')} placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')}
disabled={!session || !isConnected} disabled={!session || !isConnected}
// Deaktiviert wenn kein Session oder WebSocket nicht verbunden kein Senden ohne Verbindung.
/> />
<button <button
type="submit" type="submit"
disabled={!session || !messageInput.trim() || !isConnected} disabled={!session || !messageInput.trim() || !isConnected}
// Aktiv nur wenn: Session vorhanden + Text eingegeben + WebSocket verbunden.
> >
{t('chat.send')} {t('chat.send')}
</button> </button>
</form> </form>
</section> </section>
<aside className="participants-panel"> <aside className="participants-panel">
<header> <header>
<h2>{t('participants.title')}</h2> <h2>{t('participants.title')}</h2>
@@ -333,6 +473,7 @@ function App() {
) : ( ) : (
participants.map((participant) => ( participants.map((participant) => (
<li key={participant.id}> <li key={participant.id}>
{/* Presence-Punkt: CSS-Klasse 'online' oder 'away' je nach isOnline-Status. */}
<span <span
className={`presence ${participant.isOnline ? 'online' : 'away'}`} className={`presence ${participant.isOnline ? 'online' : 'away'}`}
aria-label={participant.isOnline ? t('participants.online') : 'offline'} aria-label={participant.isOnline ? t('participants.online') : 'offline'}
@@ -355,6 +496,10 @@ function App() {
) )
} }
// Ausserhalb der Komponente — wird nicht bei jedem Render neu erstellt.
// Intl.DateTimeFormat: Eingebaute Browser-API, kein extra Paket nötig.
// 'de-DE' formatiert im 24-Stunden-Format (13:45).
function formatTime(timestamp) { function formatTime(timestamp) {
if (!timestamp) return '' if (!timestamp) return ''
return new Intl.DateTimeFormat('de-DE', { return new Intl.DateTimeFormat('de-DE', {
@@ -363,6 +508,9 @@ function formatTime(timestamp) {
}).format(new Date(timestamp)) }).format(new Date(timestamp))
} }
// Intl.RelativeTimeFormat: Gibt "vor 5 Minuten", "gestern" etc. aus.
// numeric: 'auto' → "gestern" statt "vor 1 Tag".
// Berechnung: Minuten → Stunden → Tage (von klein nach gross).
function formatRelativeTime(timestamp) { function formatRelativeTime(timestamp) {
if (!timestamp) return '' if (!timestamp) return ''
const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' }) const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' })
+3
View File
@@ -35,6 +35,9 @@
"placeholderDisabled": "Verbinde dich, um zu schreiben", "placeholderDisabled": "Verbinde dich, um zu schreiben",
"send": "Senden" "send": "Senden"
}, },
"rooms": {
"title": "Aktive Räume"
},
"participants": { "participants": {
"title": "Aktive Teilnehmer", "title": "Aktive Teilnehmer",
"empty": "Niemand ist aktiv", "empty": "Niemand ist aktiv",
+112
View File
@@ -0,0 +1,112 @@
---
title: "Testprotokoll Blackbox Test"
author: "Chat App"
date: "19.03.2026"
geometry: margin=2cm
fontsize: 11pt
---
## Projektinformationen
| | |
|---|---|
| **Projekt** | Chat App |
| **Datum** | 19.03.2026 |
| **Tester** | _______________ |
| **Version** | 1.0 |
---
## Testumgebung
| | |
|---|---|
| **Browser** | Chrome / Firefox |
| **URL** | http://localhost |
| **Methode** | Manueller Blackbox Test |
---
## Beschreibung der Systemteile
| Komponente | Technologie | Aufgabe |
|---|---|---|
| **Frontend** | React, Vite, Socket.IO Client, Nginx | Benutzeroberfläche für den Chat. Stellt Verbindung zum Backend über WebSocket her und zeigt Nachrichten, Teilnehmer und Verbindungsstatus an. |
| **Backend** | Node.js, Express, Socket.IO | REST-API und WebSocket-Server. Verwaltet Benutzer-Sessions, Räume und leitet Nachrichten in Echtzeit weiter. |
| **Datenbank** | MongoDB | Persistente Speicherung von Nachrichten und Benutzerdaten. Alternativ steht ein JSON-Fallback-Store zur Verfügung. |
| **Infrastruktur** | Docker, Docker Compose, Nginx | Containerisierung aller Dienste. Nginx fungiert als Reverse Proxy für das Frontend. |
---
## Vorschläge zur Ausfallsicherheit
**Frontend**
- Automatischer Reconnect bei WebSocket-Verbindungsabbruch ist bereits implementiert (5 Versuche)
- Statische Dateien über CDN ausliefern für höhere Verfügbarkeit
**Backend**
- Mehrere Instanzen hinter einem Load Balancer betreiben (z. B. Nginx upstream)
- Bei mehreren Instanzen Socket.IO mit Redis Adapter koppeln, damit alle Instanzen dieselben Räume/Events teilen
- Health-Check-Endpunkt `/health` ist bereits vorhanden und kann von einem Orchestrator (z. B. Kubernetes) genutzt werden
**Datenbank**
- MongoDB Replica Set einrichten: primärer Node schreibt, sekundäre Nodes lesen → bei Ausfall des primären Nodes übernimmt automatisch ein sekundärer
- Regelmäßige automatische Backups des Volumes einrichten
- JSON-Fallback-Store dient als Notlösung bei MongoDB-Ausfall (bereits implementiert)
**Infrastruktur**
- `restart: unless-stopped` ist in allen Docker-Diensten bereits konfiguriert → automatischer Neustart bei Absturz
- Für Produktion: Docker Swarm oder Kubernetes für automatisches Failover und Skalierung
---
## Testfälle
| Nr. | Beschreibung | Eingabe | Erwartetes Ergebnis | Tatsächliches Ergebnis | Status |
|---|---|---|---|---|---|
| T01 | Beitreten mit gültigem Benutzernamen und Raum | Username: `Max`, Room: `general` | Benutzer tritt dem Raum bei, Chatverlauf wird angezeigt | | [ ] |
| T02 | Beitreten ohne Benutzernamen | Username: *(leer)*, Room: `general` | Fehlermeldung wird angezeigt, kein Beitritt | | [ ] |
| T03 | Beitreten ohne Raumnamen | Username: `Max`, Room: *(leer)* | Fehlermeldung wird angezeigt, kein Beitritt | | [ ] |
| T04 | Nachricht senden | Nachricht: `Hallo!` → Senden | Nachricht erscheint im Chat | | [ ] |
| T05 | Leere Nachricht senden | Nachricht: *(leer)* → Senden | Nachricht wird nicht gesendet | | [ ] |
| T06 | Zwei Benutzer im selben Raum | Tab 1: `Max` / `general`, Tab 2: `Anna` / `general` | Beide sehen Nachrichten des jeweils anderen in Echtzeit | | [ ] |
| T07 | Benutzer in verschiedenen Räumen | Tab 1: `Max` / `general`, Tab 2: `Anna` / `privat` | Nachrichten sind voneinander getrennt | | [ ] |
| T08 | Teilnehmerliste aktualisiert sich | Tab 2 schließen | Tab 1 zeigt Benutzer als offline/entfernt | | [ ] |
| T09 | Chatverlauf nach Neuverbindung | Seite neu laden, wieder einloggen | Vorherige Nachrichten werden geladen | | [ ] |
| T10 | Sonderzeichen in Nachricht | Nachricht: `<script>alert(1)</script>` | Text wird als plain text angezeigt, kein Script ausgeführt | | [ ] |
| T11 | Langer Text | Nachricht mit 500 Zeichen | Nachricht vollständig angezeigt, kein Layout-Fehler | | [ ] |
| T12 | Backend nicht erreichbar | Backend stoppen, App öffnen | Fehlermeldung oder Reconnect-Versuch wird angezeigt | | [ ] |
---
## Legende
| Symbol | Bedeutung |
|---|---|
| [OK] | Bestanden |
| [X] | Fehlgeschlagen |
| [ ] | Nicht getestet |
---
## Gesamtergebnis
| Gesamt | Bestanden | Fehlgeschlagen | Nicht getestet |
|---|---|---|---|
| 12 | | | |
---
## Bemerkungen
\vspace{1cm}
_______________________________________________
\vspace{1cm}
_______________________________________________