Unterschiede

Hier werden die Unterschiede zwischen zwei Versionen der Seite angezeigt.

Link zu der Vergleichsansicht

Beide Seiten, vorherige Überarbeitung Vorherige Überarbeitung
Nächste Überarbeitung
Vorherige Überarbeitung
talit:web:webapps:server [2025-04-09 11:25] – [Sicherheit] hoftalit:web:webapps:server [2025-04-13 10:59] (aktuell) hof
Zeile 55: Zeile 55:
 Mit dem Kommandozeilenaufruf `node app.js` wird der Node-Server gestartet. Auf der Adresse http://localhost:3001/hello können wir auf die oben definierte API zugreifen. Mit dem Kommandozeilenaufruf `node app.js` wird der Node-Server gestartet. Auf der Adresse http://localhost:3001/hello können wir auf die oben definierte API zugreifen.
  
-Der Code bedeutet: Wann immer eine Anfrage für den Pfad `/hello` ankommt, so sende als _Response_ (_res_) den String `Hello World` zurück.+Der Code bedeutet: Wann immer eine _Request_ (_res_) für den Pfad `/hello` ankommt, so sende als _Response_ (_res_) den String `Hello World` zurück.
  
-Es ist auch möglich, Teile der im Request (_req_) verlangten Adresse als Parameter zu erhalten:+Es ist auch möglich, Teile des im Request verlangten Pfades als Parameter zu erhalten:
  
 <code javascript> <code javascript>
Zeile 65: Zeile 65:
 }) })
 </code> </code>
 +
 ### Statische Dateien ### Statische Dateien
 Die bisherigen HTML und CSS Dateien bleiben unverändert - der Server soll diese bitte direkt zum Browser übertragen. Das erreichen wir, indem wir einen Ordner `static` anlegen, und die client-side Dateien dorthin verschieben. Node wird instruiert, diese Dateien unverändert auszuliefern: Die bisherigen HTML und CSS Dateien bleiben unverändert - der Server soll diese bitte direkt zum Browser übertragen. Das erreichen wir, indem wir einen Ordner `static` anlegen, und die client-side Dateien dorthin verschieben. Node wird instruiert, diese Dateien unverändert auszuliefern:
Zeile 76: Zeile 77:
  
 Ausprobieren: Node neu starten und den URL http://localhost:3001/0/connect4.html laden - unser HTML + CSS sollte korrekt angezeigt werden! Allenfalls ist der Port 3001 bereits besetzt, dann muss ein anderer Port gewählt werden. Ausprobieren: Node neu starten und den URL http://localhost:3001/0/connect4.html laden - unser HTML + CSS sollte korrekt angezeigt werden! Allenfalls ist der Port 3001 bereits besetzt, dann muss ein anderer Port gewählt werden.
 +
 ### Server-Side Vier-Gewinnt ### Server-Side Vier-Gewinnt
  
-Erstelle eine Kopie deiner `connect4.js` Datei, um auf dem Server ein Spiel laufen zu lassen.+Erstelle eine Kopie deiner `connect4.js` Datei als `connect4_server.js`, um auf dem Server ein Spiel laufen zu lassen.
  
 Was muss sich ändern? Was muss sich ändern?
Zeile 85: Zeile 87:
  
  
-In `app.js` erstellen wir ein Spiel, z.B. mit `let game = newGame()`. Damit das funktioniert, müssen wir alle Funktionen oder Klassen in `connect4.js` mit `export` markieren und in `app.js` importieren:+In `app.js` erstellen wir ein Spiel, z.B. mit `let game = newGame()`. Damit das funktioniert, müssen wir alle Funktionen oder Klassen in `connect4_server.js` mit `export` markieren und in `app.js` importieren:
  
-<code javascript connect4.js>+<code javascript connect4_server.js> 
 +/* Returns a fresh game. */
 export function newGame() { export function newGame() {
 +  return {
 +    "state": "waiting",  // or "waiting" or "won" or "tie"
 +    "board": [
 +      0, 0, 0, 0, 0, 0, 0,
 +      0, 0, 0, 0, 0, 0, 0,
 +      0, 0, 0, 0, 0, 0, 0,
 +      0, 0, 0, 0, 0, 0, 0,
 +      0, 0, 0, 0, 0, 0, 0,
 +      0, 0, 0, 0, 0, 0, 0,
 +    ],
 +    "next": 1,  // 1 or 2, the player whose turn it is, the winner if state is "won"
 +    "player1": undefined,
 +    "player2": undefined,
 +  }
 +}
 +
 +/* Drops a piece into given column. */
 +export function dropPiece(game, column) {
   ...   ...
 } }
Zeile 115: Zeile 136:
   * Wie weiss der Browser, ob er gelb oder rot spielt?   * Wie weiss der Browser, ob er gelb oder rot spielt?
   * Wie können mehrere Spiele betreut werden (Hinweis: schau dir die `gameid` oben an...!)   * Wie können mehrere Spiele betreut werden (Hinweis: schau dir die `gameid` oben an...!)
- 
  
 ### Client-Side Javascript ### Client-Side Javascript
-Der Browser benötigt auch Javascript. Der Click-Handler jedes Buttons soll das `set` API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen.+Der Browser benötigt weiterhin Javascript für den Informationsaustausch mit dem Server. Der Click-Handler jedes Buttons soll das `set` API aufrufen und anschliessend den Inhalt des HTMLs an den Spielzustand anpassen.
  
 Weil alle JSON Schnittstellen so konzipiert sind, dass sie im Pfad unterhalb des Seiten-URLs stehen, muss der Client gar nicht wissen, was seine Game-Id ist. Stattdessen ruft er eine **relative** URL auf: Weil alle JSON Schnittstellen so konzipiert sind, dass sie im Pfad unterhalb des Seiten-URLs stehen, muss der Client gar nicht wissen, was seine Game-Id ist. Stattdessen ruft er eine **relative** URL auf:
Zeile 126: Zeile 146:
     * Schnittstelle für Spielzug: `http://localhost:3001/42/set/4`     * Schnittstelle für Spielzug: `http://localhost:3001/42/set/4`
  
-Wir können also aus dem client-side Javascript einfach den relativen URL `game` aufrufen, der Browser setzt automatisch den Seitenpfad vorne dran.+Wir können also aus dem client-side Javascript einfach die relativen URLs `game` oder `set/4` aufrufen, der Browser setzt automatisch den Seitenpfad vorne dran ([[https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references|MDM: Resolving relative references]]).
  
  
Zeile 175: Zeile 195:
 init(); init();
 </code> </code>
 +
 +\\
 ### User Ids & Cookies ### User Ids & Cookies
 Um sicherzustellen, dass jede:r Benutzer:in nur in einem Spiel ist, und kein böswilliger Akteur einen falschen Spielzug ausführen kann, wollen wir eine Benutzer-Id für jede Verbindung erstellen. Dafür nützen wir Cookies. Das Cookie wird gesetzt wenn zum ersten Mal einem Spiel beigetreten wird: Um sicherzustellen, dass jede:r Benutzer:in nur in einem Spiel ist, und kein böswilliger Akteur einen falschen Spielzug ausführen kann, wollen wir eine Benutzer-Id für jede Verbindung erstellen. Dafür nützen wir Cookies. Das Cookie wird gesetzt wenn zum ersten Mal einem Spiel beigetreten wird:
Zeile 220: Zeile 242:
 }) })
 </code> </code>
 +
 +Falls ein Spieler direkt auf einen Spiel-URL geht (z.B. weil der erste Spieler den URL mit ihm geteilt hat), so muss das Spiel noch gejoint werden. Die Zustandsabfrage ändert sich also:
 +
 +<code javascript app.js>
 +/** Serve the game state of any valid game id. */
 +app.get('/:gameid/game', (req, res) => {
 +    const userid = getUserId(req, res);
 +    const game = games[parseInt(req.params['gameid'])]
 +    if (game == undefined) {
 +        res.status(404).json("no such game");
 +    } else {
 +        if (isWaiting(game, userid)) {
 +            // Game is still waiting for a second player - join it!
 +            console.log(`Game ${game.id} directly joined by ${userid}`)
 +            joinGame(game, userid)
 +        }
 +        res.json(toJson(game, userid))
 +    }
 +})
 +</code>
 +
 +Damit obiger Code funktioniert, benötigt die Game-Logik auf der server-side die Funktionen `isWaiting(game, userid)` und `joinGame(game, userid)` und `toJson(game, userid)`:
 +
 +<code javascript connect4_server.js>
 +/* Returns true if the game is waiting and userid 
 + * has not joined, false otherwise */
 +export function isWaiting(game, userid) {
 +  return game.state == "waiting" && game.player1 != userid;
 +}
 +
 +/* Lets userid join the given game, throws an error if
 + * the game cannot be joined. */
 +export function joinGame(game, userid) {
 +  if (!isWaiting(game, userid)) {
 +    throw Error("Not waiting!");
 +  }
 +  if (game.player1 == undefined) {
 +    game.player1 = userid;
 +  } else {
 +    game.player2 = userid;
 +    game.state = "playing";
 +  }
 +}
 +
 +/* Returns the game state ready to be sent to the client. */
 +export function toJson(game, userid) {
 +  // TODO: create a copy of game using structuredClone and
 +  // set game.myturn=true if the current player is userid.
 +  return game;
 +}
 +</code>
 +\\
  
 ### Sicherheit ### Sicherheit
 Nun können wir auch sicherstellen, dass nur ein berechtigter Spieler einen Stein setzen kann. In der `/:gameid/set/` Schnittstelle wird die Benutzer-Id ausgelesen und überprüft, ob der Spieler überhaupt im Spiel mitspielt, und ob er an der Reihe ist. Andernfalls wird ein HTTP Status-Code aus dem [[wp>List_of_HTTP_status_codes#4xx_client_errors|400er-Bereich]] zurückgegeben, z.B. `403` (_Forbidden_). Nun können wir auch sicherstellen, dass nur ein berechtigter Spieler einen Stein setzen kann. In der `/:gameid/set/` Schnittstelle wird die Benutzer-Id ausgelesen und überprüft, ob der Spieler überhaupt im Spiel mitspielt, und ob er an der Reihe ist. Andernfalls wird ein HTTP Status-Code aus dem [[wp>List_of_HTTP_status_codes#4xx_client_errors|400er-Bereich]] zurückgegeben, z.B. `403` (_Forbidden_).
  
-<code javascript>+<code javascript app.js>
 /** Make a play. Only allows the joined players to  */ /** Make a play. Only allows the joined players to  */
 app.get('/:gameid/set/:column', (req, res) => { app.get('/:gameid/set/:column', (req, res) => {
Zeile 244: Zeile 318:
 </code> </code>
  
 +<code javascript connect4_server.js>
 +/* Returns the playerid whose turn it is. */
 +export function getCurrentPlayer(game) {
 +  return game.next == 1 ? game.player1 : game.player2;
 +}
 +</code>
 +
 +\\
 ### Wer ist dran? ### Wer ist dran?
  
 Damit der Client weiss, ob er an der Reihe ist, müsste er seine eigene User-Id kennen. Wir könnten aber die Information auch einfach in das zurückgegebene JSON einbauen. Eine Möglichkeit wäre, dass wir für jede Zustandsabfrage überprüfen, ob der UserId des Requests gerade am Zug ist. Damit wir die Eigenschaft nicht auf der Server-Seite speichern, erstellen wir eine Kopie des JSON mit `structuredClone` ([[https://developer.mozilla.org/de/docs/Web/API/Window/structuredClone|MDM]]). Damit der Client weiss, ob er an der Reihe ist, müsste er seine eigene User-Id kennen. Wir könnten aber die Information auch einfach in das zurückgegebene JSON einbauen. Eine Möglichkeit wäre, dass wir für jede Zustandsabfrage überprüfen, ob der UserId des Requests gerade am Zug ist. Damit wir die Eigenschaft nicht auf der Server-Seite speichern, erstellen wir eine Kopie des JSON mit `structuredClone` ([[https://developer.mozilla.org/de/docs/Web/API/Window/structuredClone|MDM]]).
  
-<code javascript>+<code javascript connect4_server.js>
 export function toJson(game, userid) { export function toJson(game, userid) {
   let copy = structuredClone(game); // make a copy   let copy = structuredClone(game); // make a copy
Zeile 263: Zeile 345:
 } }
 </code> </code>
- 
 ### Polling ### Polling
  
Zeile 270: Zeile 351:
 Damit wir alle 500ms oder jede Sekunde den Serverzustand abfragen, bietet es sich an, wieder mit `await` zu arbeiten. Mit Promises können wir eine `async` Funktion bauen, die nebenläufig eine bestimme Zeitdauer wartet. Am Ende der `fetchUrl` Funktion rufen wir diese auf, bevor wir den Gamezustand mit einem Selbstaufruf pollen: Damit wir alle 500ms oder jede Sekunde den Serverzustand abfragen, bietet es sich an, wieder mit `await` zu arbeiten. Mit Promises können wir eine `async` Funktion bauen, die nebenläufig eine bestimme Zeitdauer wartet. Am Ende der `fetchUrl` Funktion rufen wir diese auf, bevor wir den Gamezustand mit einem Selbstaufruf pollen:
  
-<code javascript>+<code javascript connect4_client.js>
 async function wait(ms) { async function wait(ms) {
   // Schläft für eine Anzahl Millsekunden, bevor die Promise settlet.   // Schläft für eine Anzahl Millsekunden, bevor die Promise settlet.
Zeile 295: Zeile 376:
 </code> </code>
  
 +### Long-Polling
 +Mit der obigen Lösung fragt jeder Client zweimal pro Sekunde nach einem Update. Polling hat zwei Nachteile:
 +  * es wird relative viel Traffic erzeugt, insbesondere, wenn ausser der zwei Spieler noch zahlreiche Beobachter auf Änderungen warten.
 +  * trotzdem ist die Responsivität tief - im Mittel wartet jeder Client die Hälfte des eingestellten Delays, also 250 Millisekunden. Das ist für Vier-Gewinnt zwar ausreichend tief, aber nicht genug für Spiele mit Interaktivität.
 +
 +Um das zu beheben gibt es zwei Lösungsmöglichkeiten:
 +  - **Long-Polling**: Die Requests für den Spielzustand werden vom Server nicht sofort beantwortet, sondern erst, wenn sich der Zustand geändert hat.
 +  - **Web-Sockets**: Das Websockets-Protokoll erlaubt bidirektionale Verbindungen zwischen Client und Server. Über eine solche Verbindung werden Nachrichten über den Spielzustand ausgetauscht.
 +
 +Hier werden wir Long-Polling anschauen. Als erstes benötigen wir eine Datenstruktur, um die Requests für später zu speichern.
 +<code javascript app.js>
 +/** All long-poll requests by gameid. For each gameid, a list of 
 +  * [response, userid] entries is stored.  */
 +let longpolls = {};
 +</code>
 +
 +Dazu definieren wir eine neue Route `/<gameid>/longpoll`, die ganz ähnlich wie `/<gameid>/game` funktioniert:
 +
 +<code javascript app.js>
 +/** Serve the game state of any valid game id, but block until
 + * the client is no longer blocked (long-polling). */
 +app.get('/:gameid/longpoll', (req, res) => {
 +    const userid = getUserId(req, res);
 +    const game = games[parseInt(req.params['gameid'])]
 +    if (game == undefined) {
 +        res.status(404).json("no such game");
 +    } else {
 +        if (isWaiting(game, userid)) {
 +            console.log(`Game ${game.id} directly joined by ${userid}`)
 +            join(game, userid)
 +            res.json(toJson(game, userid))
 +            res.end()
 +            return
 +        }
 +        // Check if userid needs to wait, then either:
 +        // - stash the [response, userid] pair for later
 +        // - instantly return actionable state
 +        if (shouldBlockRequest(game, userid)) {
 +            console.log(`Stash long-poll request for ${game.id} by ${userid}`)
 +            longpolls[game.id] ??= []
 +            longpolls[game.id].push([res, userid])
 +            // do not end here but keep request hanging.
 +        } else {
 +            // User can act on the state, no point in waiting.
 +            res.json(toJson(game, userid))
 +            res.end()
 +        }
 +    }
 +})
 +</code>
 +
 +Im der server-side Game-Logik implementieren wir eine neue Funktion, um zu wissen, ob ein Request blockiert werden soll oder nicht - genau dann, wenn der Zustand `"waiting"` ist oder wenn der User gerade nicht am Zug ist (User hat als letztes gespielt, oder User ist ein Beobachter):
 +
 +<code javascript connect4_server.js>
 +export function shouldBlockRequest(game, userid) {
 +  return game.state == "waiting" || game.state == "playing" && getCurrentPlayer(game) != userid;
 +}
 +</code>
 +
 +Nun benötigen wir noch die Logik, um die Long-Poll-Requests zu beantworten, sobald sich der Spielzustand geändert hat. Die Funktion wird aufgerufen, wenn ein Spiel gejoint wird, oder wenn ein Spielzug gemacht wird.
 +
 +<code javascript app.js>
 +/** Sends any pending long-polling responses after a state change. */
 +function sendLongPollResponses(game) {
 +    let polls = longpolls[game.id]
 +    delete longpolls[game.id]  // remove
 +    if (polls) {
 +        for (let response of polls) {
 +            // a response object is a list with the actual HTTP response and the userid.
 +            let res = response[0]
 +            let userid = response[1]
 +            console.log(`End long-poll request for ${game.id} by ${userid}`)
 +            res.json(toJson(game, userid))
 +            res.end()
 +        }
 +    }
 +}
 +</code>
 +
 +Was bleibt zu tun? Der Client soll nicht mehr warten, sondern bei Bedarf das `longpoll` API benützen:
 +
 +<code javascript connect4_client.js>
 +async function handleFetch(grid, status, url) {
 +  let response = await fetch(url);
 +  if (response.ok) {
 +    game = await response.json();
 +    // Update the HTML view.
 +    updateHtml(grid, game);
 +    // Update game status area to reflect winner / tie
 +    updateStatus(status, game);
 +  }
 +  if (!response.ok) {
 +    await wait(500) // wait a little if there is connection trouble
 +  }
 +  if (!response.ok || game.state == "waiting" || game.state == "playing" && !game.myturn) {
 +    await handleFetch(grid, status, `longpoll`);  // longpoll to wait for state change
 +  }
 +}
 +</code>
 +
 +Nun haben wir viel weniger Datenverkehr, der Server wird entlastet - dafür muss er sich mehr Informationen speichern. Ein Vorteil ist auch, dass Änderungen schneller beim long-polling Client ankommen.
  
 ### Hinweise ### Hinweise
  
-  * Die ganze Web-App mit JS & Node: https://github.com/tkilla77/ksr_talit_connect4+  * Die ganze Web-App mit JS & Node: https://github.com/tkilla77/ksr_talit_connect4
  
  • talit/web/webapps/server.1744197931.txt.gz
  • Zuletzt geändert: 2025-04-09 11:25
  • von hof