Hoppa till innehåll

Realtid med WebSocket

Med fetch() frågar klienten och servern svarar. Det räcker långt, men inte när servern har något att berätta utan att någon har frågat. Den här artikeln går igenom WebSocket, tekniken bakom chattar, spel och annat som ska uppdateras direkt. Du behöver den för Messages i examinationsuppgift B3.

Tidsåtgång: Ungefär 15 minuter.

HTTP togs fram för att hämta dokument, och det är alltid klienten som börjar. Tänk dig en omröstning där resultatet ska uppdateras hos alla medan rösterna kommer in. Att skicka en röst med fetch() är inget problem. Svårigheten är hur alla andra klienter får veta att det har kommit en ny röst.

Med bara HTTP är det enda sättet att fråga, om och om igen. Det kallas polling:

setInterval(async () => {
const response = await fetch('/votes')
// ...
}, 5000)

Det har två nackdelar som drar åt olika håll. Ett långt intervall gör att resultatet släpar efter. Ett kort intervall gör att servern får svara på en ström av förfrågningar som nästan alltid får svaret att inget har hänt, och varje förfrågan bär med sig sina headrar.

En äldre förbättring är long polling: servern håller förfrågan öppen tills det finns något att svara med, och klienten skickar sedan en ny. Det minskar antalet förfrågningar, men det är fortfarande ett sätt att komma runt att servern inte kan börja prata själv.

WebSocket är ett eget protokoll, med adresser som börjar med ws:// eller, krypterat, wss://. Det börjar ändå som HTTP. Klienten skickar en vanlig förfrågan med headern Upgrade: websocket, och om servern går med på det svarar den med statuskoden 101 Switching Protocols. Samma TCP-anslutning används sedan för WebSocket. Eftersom portarna är desamma som för HTTP, 80 och 443, släpps trafiken oftast igenom där webben gör det.

Sekvensdiagram med klient och server i två delar. Polling över HTTP: klienten skickar GET /votes tre gånger, och servern svarar två gånger att inget är nytt och en gång med nytt resultat. WebSocket: klienten skickar GET /socket med headern Upgrade: websocket, och servern svarar 101 Switching Protocols. Anslutningen är sedan öppen och båda sidor kan skicka när som helst. Servern skickar nytt resultat, klienten skickar en röst, servern skickar nytt resultat och ett heartbeat. Till sist stängs anslutningen.

Efter handskakningen är anslutningen öppen åt båda håll, och båda sidor kan skicka när de vill. Varje meddelande kostar bara några få byte extra, så det gör inget att skicka ofta. WebSocket passar därför små meddelanden som kommer tätt, som chattmeddelanden och positioner i ett spel.

Behöver du bara att servern skickar till klienten finns också server-sent events, som går över vanlig HTTP. I B3 är det WebSocket som gäller, och servern finns redan. Du skriver bara klienten.

const socket = new WebSocket('wss://example.com/poll')
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ type: 'vote', data: 'ja' }))
})
socket.addEventListener('message', (event) => {
console.log(event.data)
})
socket.addEventListener('close', (event) => {
console.log('Stängd', event.code)
})

API:et är från tiden före promises, så det bygger helt på händelser:

  • open utlöses när handskakningen är klar. Först då går det att skicka.
  • message utlöses för varje meddelande som kommer in. Innehållet finns i event.data.
  • error utlöses när något går fel, men händelsen säger inte vad. Av säkerhetsskäl får du aldrig veta orsaken i din kod, den syns bara i konsolen.
  • close utlöses när anslutningen stängs, av vilket skäl det än är. Det gäller också när den aldrig kom igång: då kommer error först och close direkt efter.

close har event.code och event.wasClean. Kod 1000 är en normal stängning. Kod 1006 skickas aldrig av någon server, utan är webbläsarens sätt att säga att anslutningen försvann utan en ordnad stängning. Det är vad du ser när servern inte går att nå.

Du kan själv kontrollera läget med socket.readyState, som är någon av konstanterna WebSocket.CONNECTING, OPEN, CLOSING eller CLOSED.

Vad händer när den här koden körs?

const socket = new WebSocket('wss://example.com/poll')
socket.send('Hej')

Raden med send() kastar ett InvalidStateError. Konstruktorn väntar inte på handskakningen, så när nästa rad körs är readyState fortfarande CONNECTING. Det du vill skicka direkt ska ligga i en lyssnare på open.

Omvänt är det tystare. Anropar du send() när anslutningen är stängd eller håller på att stängas kastas inget fel, och meddelandet försvinner bara. Därför behöver du själv kontrollera readyState innan du skickar.

send() tar en sträng eller binärdata. Det finns ingen inbyggd struktur i meddelandena, så det vanliga är JSON med ett fält som talar om vilken sorts meddelande det är:

socket.addEventListener('message', (event) => {
let message
try {
message = JSON.parse(event.data)
} catch {
return
}
if (message.type === 'results') {
showResults(message.data)
}
})

try finns där för att du inte bestämmer vad som kommer över anslutningen, och en lyssnare som kastar ett fel hjälper ingen. Koden gör bara något med de typer den känner till, och allt annat ignoreras. Det är precis så ett heartbeat ska hanteras i B3. Servern skickar ett var 40:e sekund för att anslutningen inte ska stängas när det är tyst, eftersom servrar och proxyservrar ofta stänger anslutningar som varit tysta länge. Hur länge varierar, och protokollet självt anger ingen gräns.

Konstruktorn tar ett andra, frivilligt argument:

const socket = new WebSocket('wss://example.com/poll', 'poll-v1')

Det är ett subprotokoll, ett namn på det format som klient och server ska prata. Servern väljer ett av namnen klienten föreslår, och det valda finns sedan i socket.protocol. Namnet går inte att hitta på bara på klientsidan, utan servern måste känna till det. Servern i B3 använder inget subprotokoll, så du behöver inte argumentet där.

I fliken Network hittar du anslutningen under filtret Socket, eller WS beroende på webbläsare. Klicka på den och välj fliken Messages, så ser du varje meddelande i båda riktningarna med tid och innehåll. Det är det snabbaste sättet att se vad servern faktiskt skickar. Där syns också heartbeat-meddelandena.

En socket är en resurs som komponenten äger, precis som en timer. Den öppnas i connectedCallback() och stängs i disconnectedCallback():

src/js/components/live-poll/live-poll.js
const SERVER_URL = 'wss://example.com/poll'
export class LivePoll extends HTMLElement {
/** @type {WebSocket} */
#socket
/** @type {AbortController} */
#abortController
connectedCallback() {
this.#abortController = new AbortController()
const { signal } = this.#abortController
this.#socket = new WebSocket(SERVER_URL)
this.#socket.addEventListener('message', (event) => this.#onMessage(event), { signal })
this.#socket.addEventListener('close', () => this.#showDisconnected(), { signal })
this.shadowRoot.querySelector('form').addEventListener('submit', (event) => this.#onSubmit(event), {
signal
})
}
disconnectedCallback() {
this.#abortController.abort()
this.#socket.close()
}
/**
* Sends the vote, unless the connection is unavailable.
*
* @param {SubmitEvent} event - The submit event.
*/
#onSubmit(event) {
event.preventDefault()
if (this.#socket.readyState !== WebSocket.OPEN) {
this.#status.textContent = 'Ingen anslutning. Rösten skickades inte.'
return
}
const data = new FormData(event.target).get('option')
this.#socket.send(JSON.stringify({ type: 'vote', data }))
}
// #onMessage(), #showDisconnected() and #status left out.
}

Lyssnarna på socketen tar samma signal som alla andra lyssnare i komponenten. Det får en praktisk följd: när disconnectedCallback() har anropat abort() finns ingen lyssnare kvar på close, så komponenten försöker inte visa något i ett element som redan är borta. Har du lagt till återanslutning i lyssnaren slipper du på samma sätt att hålla reda på om komponenten fortfarande finns, eftersom ingen lyssnare längre försöker öppna en ny anslutning.

Varje instans får sin egen socket. Öppnar användaren två chattfönster i PWD:et har de alltså två oberoende anslutningar, och var och en får sin egen kopia av varje meddelande.

I ett PWD ska fönstret användaren klickar på hamna överst. En student löser det genom att flytta fönstret sist i sin förälder, eftersom det som kommer sist i DOM:en ritas ovanpå:

desktop.append(clickedWindow)

Fönstret innehåller chatten ovan. Vad händer med anslutningen varje gång användaren klickar på fönstret?

Den stängs och öppnas igen. När ett element som redan finns i DOM:en flyttas med append() tas det först bort och sätts sedan in, så disconnectedCallback() och connectedCallback() körs båda. Chatten får en ny socket vid varje klick, och meddelanden som kommer under tiden går förlorade.

Det är därför z-index är ett bättre sätt att lägga ett fönster överst. Det ändrar utseendet, inte var elementet finns i DOM:en. Samma sak gäller allt annat som startas i connectedCallback().

Låt föräldern hålla en räknare och ge fönstret som får fokus ett högre värde än de andra, som i förslaget i B3-mallen. z-index kräver normalt att elementet har en position, vilket ett fönster som går att flytta redan har. Tabbordningen följer fortfarande DOM:en, så flytta vid behov fokus in i fönstret som aktiveras.

Ett meddelande över en WebSocket är text som någon annan har skrivit. I en chatt är det bokstavligen andra användare. Det får aldrig tolkas som HTML.

Så här visas ett inkommet chattmeddelande:

item.innerHTML = `<strong>${message.username}</strong>: ${message.data}`

Någon skickar ett meddelande där data är <img src="x" onerror="alert(1)">. Vad händer hos alla andra som har chatten öppen?

Koden i onerror körs hos alla. Bilden går inte att hämta, felhändelsen utlöses, och JavaScript som en främling har skrivit körs med full tillgång till sidan, till exempel API-nyckeln och det som finns i webblagringen. Det är cross-site scripting (XSS). Att fältet heter username gör det inte säkrare, eftersom det också skickas av klienten.

Sätt texten med textContent, så blir den text och inget annat:

const name = document.createElement('strong')
name.textContent = message.username
item.append(name, `: ${message.data}`)

append() med en sträng skapar en textnod och tolkar inte heller något som HTML.

Några saker till att tänka på:

  • Använd wss://. En sida som laddats över HTTPS får inte öppna en okrypterad ws://-anslutning, och utan kryptering kan vem som helst på vägen läsa trafiken.
  • WebSocket har inget stöd för egna headrar. Det går inte att skicka med en API-nyckel i en header som med fetch(). Därför ligger nyckeln i B3 i själva meddelandet.
  • Same-origin policy gäller inte på samma sätt. En WebSocket får öppnas mot en annan origin, utan CORS. Webbläsaren skickar headern Origin, och det är upp till servern att avgöra vad den släpper in.
  • En nyckel i klientkod är inte hemlig. Alla som öppnar DevTools kan läsa den. Servern i B3 är byggd för det, men i en riktig tjänst ska det som verkligen är hemligt ligga på en server. Oavsett ska nyckeln inte ligga i kod som är publik.

Med det här på plats är det mesta av kommunikationen i Messages gjord. Det som är kvar att bestämma själv är bland annat:

  • Vad händer om anslutningen bryts? Användaren ska åtminstone få veta det. Hur du kan försöka igen och hur du visar läget går igenom i Online, offline och Lie-Fi.
  • Var sparas användarnamnet? Det ska finnas kvar efter en omstart, och Web Storage är byggt för det.
  • Hur håller du listan till en rimlig längd? Kravet gäller meddelanden sedan fönstret öppnades, så de som ska visas behöver du hålla reda på själv.
  • Vad händer offline? En WebSocket passerar inte service workern och kan inte svaras på från en cache, som du såg i kursvecka 15.
  • Med HTTP är det klienten som börjar, och polling är ett dyrt sätt att vänta på nyheter.
  • En WebSocket börjar som en HTTP-förfrågan och uppgraderas till en öppen anslutning där båda sidor kan skicka när som helst.
  • open, message, error och close täcker API:et. send() före open kastar ett fel, och efter stängning försvinner meddelandet tyst.
  • Skicka JSON med ett fält för typen, och ignorera det du inte känner till.
  • Öppna socketen i connectedCallback() och stäng den i disconnectedCallback(). Kom ihåg att en flytt i DOM:en kör båda.
  • Text från andra sätts med textContent, aldrig med innerHTML.
CCBY