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.
Varför HTTP inte räcker
Section titled “Varför HTTP inte räcker”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.
En öppen anslutning
Section titled “En öppen anslutning”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.
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.
API:et
Section titled “API:et”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:
openutlöses när handskakningen är klar. Först då går det att skicka.messageutlöses för varje meddelande som kommer in. Innehållet finns ievent.data.errorutlö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.closeutlö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å kommererrorförst ochclosedirekt 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.
Fråga: varför kommer inget fram?
Section titled “Fråga: varför kommer inget fram?”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.
Meddelanden i JSON
Section titled “Meddelanden i JSON”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.
Subprotokoll
Section titled “Subprotokoll”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.
Felsök i DevTools
Section titled “Felsök i DevTools”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 i en webbkomponent
Section titled “En socket i en webbkomponent”En socket är en resurs som komponenten äger, precis som en timer. Den öppnas i connectedCallback()
och stängs i disconnectedCallback():
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.
Fråga: fönstret som läggs överst
Section titled “Fråga: fönstret som läggs överst”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.
Lita inte på det som kommer in
Section titled “Lita inte på det som kommer in”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.
Fråga: ett meddelande från någon annan
Section titled “Fråga: ett meddelande från någon annan”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.usernameitem.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 okrypteradws://-anslutning, och utan kryptering kan vem som helst på vägen läsa trafiken. WebSockethar inget stöd för egna headrar. Det går inte att skicka med en API-nyckel i en header som medfetch(). 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.
Messages i B3
Section titled “Messages i B3”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.
Att ta med sig
Section titled “Att ta med sig”- 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,errorochclosetäcker API:et.send()föreopenkastar 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 idisconnectedCallback(). Kom ihåg att en flytt i DOM:en kör båda. - Text från andra sätts med
textContent, aldrig medinnerHTML.
Vidare läsning
Section titled “Vidare läsning”- The WebSocket API på MDN.
- Writing WebSocket client applications på MDN.
- CloseEvent: code på MDN, med alla stängningskoder.
- RFC 6455, själva protokollet.