This commit is contained in:
Ilya Kantor 2019-03-20 23:27:04 +03:00
parent 1985d1cf19
commit 3b06ab36c5
4 changed files with 555 additions and 4 deletions

View file

@ -174,8 +174,6 @@ In the old scripts you may also find `xhr.responseText` and even `xhr.responseXM
They exist for historical reasons, to get either a string or XML document. Nowadays, we should set the format in `xhr.responseType` and get `xhr.response` as demonstrated above.
```
## Ready states
`XMLHttpRequest` changes between states as it progresses. The current state is accessible as `xhr.readyState`.
@ -447,7 +445,7 @@ function upload(file) {
## Cross-origin requests
`XMLHttpRequest` can make cross-domain requests, using the same CORS policy as `fetch`.
`XMLHttpRequest` can make cross-domain requests, using the same CORS policy as [fetch](info:fetch-crossorigin).
Just like `fetch`, it doesn't send cookies and HTTP-authorization to another origin by default. To enable them, set `xhr.withCredentials` to `true`:
@ -456,7 +454,7 @@ let xhr = new XMLHttpRequest();
*!*
xhr.withCredentials = true;
*/!*
s
xhr.open('POST', 'http://anywhere.com/request');
...
```

View file

@ -0,0 +1,505 @@
# WebSocket
The `WebSocket` protocol, described in the specification [RFC 6455](http://tools.ietf.org/html/rfc6455) provides a way to exchange data between browser and server via a persistent connection.
Once a websocket connection is established, both client and server may send the data to each other.
WebSocket is especially great for services that require continuous data exchange, e.g. online games, real-time trading systems and so on.
## A simple example
To open a websocket connection, we need to create `new WebSocket` using the special protocol `ws` in the url:
```js
let socket = new WebSocket("*!*ws*/!*://javascript.info");
```
There's also encrypted `wss://` protocol. It's like HTTPS for websockets.
Once the socket is created, we should listen for events on it. There are totally 4 events:
- **`open`** -- connection established,
- **`message`** -- data received,
- **`error`** -- websocket error,
- **`close`** -- connection closed.
...And if we'd like to send something, then `socket.send(data)` will do that.
Here's an example:
```js run
let socket = new WebSocket("ws://javascript.local/article/websocket/demo/hello");
*!*socket.onopen*/!* = function(e) {
alert("[open] Connection established, send -> server");
*!*socket.send*/!*("My name is John");
};
*!*socket.onmessage*/!* = function(event) {
alert(`[message] Data received: ${event.data} <- server`);
};
*!*socket.onclose*/!* = function(event) {
if (event.wasClean) {
alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
// e.g. server process killed or network down
// event.code is usually 1006 in this case
alert('[close] Connection died');
}
};
*!*socket.onerror*/!* = function(error) {
alert(`[error] ${error.message}`);
};
```
For demo purposes, there's a small backend [server.js](demo/server.js) written in Node.js (could be even simpler). It responds with "hello", then waits 5 seconds and closes the connection.
So you'll see events `open` -> `message` -> `close`.
Quite simple, isn't it?
There are few peculiarities though, so let's talk more in-depth, step by step.
## Установление WebSocket-соединения
Протокол `WebSocket` работает *над* TCP.
Это означает, что при соединении браузер отправляет по HTTP специальные заголовки, спрашивая: "поддерживает ли сервер WebSocket?".
Если сервер в ответных заголовках отвечает "да, поддерживаю", то дальше HTTP прекращается и общение идёт на специальном протоколе WebSocket, который уже не имеет с HTTP ничего общего.
### Установление соединения
Пример запроса от браузера при создании нового объекта `new WebSocket("ws://server.example.com/chat")`:
```
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Origin: http://javascript.ru
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
```
Описания заголовков:
GET, Host
: Стандартные HTTP-заголовки из URL запроса
Upgrade, Connection
: Указывают, что браузер хочет перейти на websocket.
Origin
: Протокол, домен и порт, откуда отправлен запрос.
Sec-WebSocket-Key
: Случайный ключ, который генерируется браузером: 16 байт в кодировке [Base64](http://ru.wikipedia.org/wiki/Base64).
Sec-WebSocket-Version
: Версия протокола. Текущая версия: 13.
Все заголовки, кроме `GET` и `Host`, браузер генерирует сам, без возможности вмешательства JavaScript.
```smart header="Такой XMLHttpRequest создать нельзя"
Создать подобный XMLHttpRequest-запрос (подделать `WebSocket`) невозможно, по одной простой причине: указанные выше заголовки запрещены к установке методом `setRequestHeader`.
```
**Сервер может проанализировать эти заголовки и решить, разрешает ли он `WebSocket` с данного домена `Origin`.**
Ответ сервера, если он понимает и разрешает `WebSocket`-подключение:
```
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
```
Здесь строка `Sec-WebSocket-Accept` представляет собой перекодированный по специальному алгоритму ключ `Sec-WebSocket-Key`. Браузер использует её для проверки, что ответ предназначается именно ему.
Затем данные передаются по специальному протоколу, структура которого ("фреймы") изложена далее. И это уже совсем не HTTP.
### Расширения и подпротоколы
Также возможны дополнительные заголовки `Sec-WebSocket-Extensions` и `Sec-WebSocket-Protocol`, описывающие расширения и подпротоколы (subprotocol), которые поддерживает данный клиент.
Посмотрим разницу между ними на двух примерах:
- Заголовок `Sec-WebSocket-Extensions: deflate-frame` означает, что браузер поддерживает модификацию протокола, обеспечивающую сжатие данных.
Это говорит не о самих данных, а об улучшении способа их передачи. Браузер сам формирует этот заголовок.
- Заголовок `Sec-WebSocket-Protocol: soap, wamp` говорит о том, что по WebSocket браузер собирается передавать не просто какие-то данные, а данные в протоколах [SOAP](http://ru.wikipedia.org/wiki/SOAP) или WAMP ("The WebSocket Application Messaging Protocol"). Стандартные подпротоколы регистрируются в специальном каталоге [IANA](http://www.iana.org/assignments/websocket/websocket.xml).
Этот заголовок браузер поставит, если указать второй необязательный параметр `WebSocket`:
```js
var socket = new WebSocket("*!*ws*/!*://javascript.ru/ws", ["soap", "wamp"]);
```
При наличии таких заголовков сервер может выбрать расширения и подпротоколы, которые он поддерживает, и ответить с ними.
Например, запрос:
```
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Origin: http://javascript.ru
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
*!*
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap, wamp
*/!*
```
Ответ:
```
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
*!*
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap
*/!*
```
В ответе выше сервер указывает, что поддерживает расширение `deflate-frame`, а из запрошенных подпротоколов -- только SOAP.
### WSS
Соединение `WebSocket` можно открывать как `WS://` или как `WSS://`. Протокол `WSS` представляет собой WebSocket над HTTPS.
**Кроме большей безопасности, у `WSS` есть важное преимущество перед обычным `WS` -- большая вероятность соединения.**
Дело в том, что HTTPS шифрует трафик от клиента к серверу, а HTTP -- нет.
Если между клиентом и сервером есть прокси, то в случае с HTTP все WebSocket-заголовки и данные передаются через него. Прокси имеет к ним доступ, ведь они никак не шифруются, и может расценить происходящее как нарушение протокола HTTP, обрезать заголовки или оборвать передачу.
А в случае с `WSS` весь трафик сразу кодируется и через прокси проходит уже в закодированном виде. Поэтому заголовки гарантированно пройдут, и общая вероятность соединения через `WSS` выше, чем через `WS`.
## Формат данных
Полное описание протокола содержится в [RFC 6455](http://tools.ietf.org/html/rfc6455).
Здесь представлено частичное описание с комментариями самых важных его частей. Если вы хотите понять стандарт, то рекомендуется сначала прочитать это описание.
### Описание фрейма
В протоколе WebSocket предусмотрены несколько видов пакетов ("фреймов").
Они делятся на два больших типа: фреймы с данными ("data frames") и управляющие ("control frames"), предназначенные для проверки связи (PING) и закрытия соединения.
Фрейм, согласно стандарту, выглядит так:
<pre>
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| опкод |М| Длина тела | Расширенная длина тела |
|I|S|S|S|(4бита)|А| (7бит) | (1 байт) |
|N|V|V|V| |С| |(если длина тела==126 или 127) |
| |1|2|3| |К| | |
| | | | | |А| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Продолжение расширенной длины тела, если длина тела = 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| | Ключ маски, если МАСКА = 1 |
+-------------------------------+-------------------------------+
| Ключ маски (продолжение) | Данные фрейма ("тело") |
+-------------------------------- - - - - - - - - - - - - - - - +
: Данные продолжаются ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Данные продолжаются ... |
+---------------------------------------------------------------+
</pre>
С виду -- не очень понятно, во всяком случае, для большинства людей.
**Позвольте пояснить: читать следует слева-направо, сверху-вниз, каждая горизонтальная полоска это 32 бита.**
То есть, вот первые 32 бита:
<pre>
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| опкод |М| Длина тела | Расширенная длина тела |
|I|S|S|S|(4бита)|А| (7бит) | (1 байт) |
|N|V|V|V| |С| |(если длина тела==126 или 127) |
| |1|2|3| |К| | |
| | | | | |А| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
</pre>
Сначала идёт бит FIN (вертикальная надпись на рисунке), затем биты RSV1, RSV2, RSV3 (их смысл раскрыт ниже), затем "опкод", "МАСКА" и, наконец, "Длина тела", которая занимает 7 бит. Затем, если "Длина тела" равна 126 или 127, идёт "Расширенная длина тела", потом (на следующей строке, то есть после первых 32 бит) будет её продолжение, ключ маски, и потом данные.
А теперь -- подробное описание частей фрейма, то есть как именно передаются сообщения:
FIN: 1 бит
: Одно сообщение, если оно очень длинное (вызовом `send` можно передать хоть целый файл), может состоять из множества фреймов ("быть фрагментированным").
У всех фреймов, кроме последнего, этот фрагмент установлен в `0`, у последнего -- в `1`.
Если сообщение состоит из одного-единственного фрейма, то `FIN` в нём равен `1`.
RSV1, RSV2, RSV3: 1 бит каждый
: В обычном WebSocket равны `0`, предназначены для расширений протокола. Расширение может записать в эти биты свои значения.
Опкод: 4 бита
: Задаёт тип фрейма, который позволяет интерпретировать находящиеся в нём данные. Возможные значения:
- `0x1` обозначает текстовый фрейм.
- `0x2` обозначает двоичный фрейм.
- `0x3-7` зарезервированы для будущих фреймов с данными.
- `0x8` обозначает закрытие соединения этим фреймом.
- `0x9` обозначает PING.
- `0xA` обозначает PONG.
- `0xB-F` зарезервированы для будущих управляющих фреймов.
- `0x0` обозначает фрейм-продолжение для фрагментированного сообщения. Он интерпретируется, исходя из ближайшего предыдущего ненулевого типа.
Маска: 1 бит
: Если этот бит установлен, то данные фрейма маскированы. Более подробно маску и маскирование мы рассмотрим далее.
Длина тела: 7 битов, 7+16 битов, или 7+64 битов
: Если значение поле "Длина тела" лежит в интервале `0-125`, то оно обозначает длину тела (используется далее).
Если `126`, то следующие 2 байта интерпретируются как 16-битное беззнаковое целое число, содержащее длину тела.
Если `127`, то следующие 8 байт интерпретируются как 64-битное беззнаковое целое, содержащее длину.
Такая хитрая схема нужна, чтобы минимизировать накладные расходы. Для сообщений длиной `125` байт и меньше хранение длины потребует всего 7 битов, для бóльших (до 65536) -- 7 битов + 2 байта, ну а для ещё бóльших -- 7 битов и 8 байт. Этого хватит для хранения длины сообщения размером в гигабайт и более.
Ключ маски: 4 байта.
: Если бит `Маска` установлен в 0, то этого поля нет. Если в `1` то эти байты содержат маску, которая налагается на тело (см. далее).
Данные фрейма (тело)
: Состоит из "данных расширений" и "данных приложения", которые идут за ними. Данные расширений определяются конкретными расширениями протокола и по умолчанию отсутствуют. Длина тела должна быть равна указанной в заголовке.
### Примеры
Некоторые примеры сообщений:
- Нефрагментированное текстовое сообщение `Hello` без маски:
```
0x81 0x05 0x48 0x65 0x6c 0x6c 0x6f (содержит "Hello")
```
В заголовке первый байт содержит `FIN=1` и `опкод=0x1` (получается `10000001` в двоичной системе, то есть `0x81` -- в 16-ричной), далее идёт длина `0x5`, далее текст.
- Фрагментированное текстовое сообщение `Hello World` из трёх частей, без маски, может выглядеть так:
```
0x01 0x05 0x48 0x65 0x6c 0x6c 0x6f (содержит "Hello")
0x00 0x01 0x20 (содержит " ")
0x80 0x05 0x57 0x6f 0x72 0x6c 0x64 (содержит "World")
```
- У первого фрейма `FIN=0` и текстовый опкод `0x1`.
- У второго `FIN=0` и опкод `0x0`. При фрагментации сообщения, у всех фреймов, кроме первого, опкод пустой (он один на всё сообщение).
- У третьего, последнего фрейма `FIN=1`.
А теперь посмотрим на все те замечательные возможности, которые даёт этот формат фрейма.
### Фрагментация
Позволяет отправлять сообщения в тех случаях, когда на момент начала посылки полный размер ещё неизвестен.
Например, идёт поиск в базе данных и что-то уже найдено, а что-то ещё может быть позже.
- У всех сообщений, кроме последнего, бит `FIN=0`.
- Опкод указывается только у первого, у остальных он должен быть равен `0x0`.
### PING / PONG
В протокол встроена проверка связи при помощи управляющих фреймов типа PING и PONG.
Тот, кто хочет проверить соединение, отправляет фрейм PING с произвольным телом. Его получатель должен в разумное время ответить фреймом PONG с тем же телом.
Этот функционал встроен в браузерную реализацию, так что браузер ответит на PING сервера, но управлять им из JavaScript нельзя.
**Иначе говоря, сервер всегда знает, жив ли посетитель или у него проблема с сетью.**
### Чистое закрытие
При закрытии соединения сторона, желающая это сделать (обе стороны в WebSocket равноправны) отправляет закрывающий фрейм (опкод `0x8`), в теле которого указывает причину закрытия.
В браузерной реализации эта причина будет содержаться в свойстве `reason` события `onclose`.
**Наличие такого фрейма позволяет отличить "чистое закрытие" от обрыва связи.**
В браузерной реализации событие `onclose` при чистом закрытии имеет `event.wasClean = true`.
### Коды закрытия
Коды закрытия вебсокета `event.code`, чтобы не путать их с HTTP-кодами, состоят из 4 цифр:
`1000`
: Нормальное закрытие.
`1001`
: Удалённая сторона "исчезла". Например, процесс сервера убит или браузер перешёл на другую страницу.
`1002`
: Удалённая сторона завершила соединение в связи с ошибкой протокола.
`1003`
: Удалённая сторона завершила соединение в связи с тем, что она получила данные, которые не может принять. Например, сторона, которая понимает только текстовые данные, может закрыть соединение с таким кодом, если приняла бинарное сообщение.
### Атака "отравленный кэш"
В ранних реализациях WebSocket существовала уязвимость, называемая "отравленный кэш" (cache poisoning).
**Она позволяла атаковать кэширующие прокси-сервера, в частности, корпоративные.**
Атака осуществлялась так:
1. Хакер заманивает доверчивого посетителя (далее Жертва) на свою страницу.
2. Страница открывает `WebSocket`-соединение на сайт хакера. Предполагается, что Жертва сидит через прокси. Собственно, на прокси и направлена эта атака.
3. Страница формирует специального вида WebSocket-запрос, который (и здесь самое главное!) ряд прокси серверов не понимают.
Они пропускают начальный запрос через себя (который содержит `Connection: upgrade`) и думают, что далее идёт уже следующий HTTP-запрос.
...Но на самом деле там данные, идущие через вебсокет! И обе стороны вебсокета (страница и сервер) контролируются Хакером. Так что хакер может передать в них нечто похожее на GET-запрос к известному ресурсу, например `http://code.jquery.com/jquery.js`, а сервер ответит "якобы кодом jQuery" с кэширующими заголовками.
Прокси послушно проглотит этот ответ и закэширует "якобы jQuery".
4. В результате при загрузке последующих страниц любой пользователь, использующий тот же прокси, что и Жертва, получит вместо `http://code.jquery.com/jquery.js` хакерский код.
Поэтому эта атака и называется "отравленный кэш".
**Такая атака возможна не для любых прокси, но при анализе уязвимости было показано, что она не теоретическая, и уязвимые прокси действительно есть.**
Поэтому придумали способ защиты -- "маску".
### Маска для защиты от атаки
Для того, чтобы защититься от атаки, и придумана маска.
*Ключ маски* -- это случайное 32-битное значение, которое варьируется от пакета к пакету. Тело сообщения проходит через XOR `^` с маской, а получатель восстанавливает его повторным XOR с ней (можно легко доказать, что `(x ^ a) ^ a == x`).
Маска служит двум целям:
1. Она генерируется браузером. Поэтому теперь хакер не сможет управлять реальным содержанием тела сообщения. После накладывания маски оно превратится в бинарную мешанину.
2. Получившийся пакет данных уже точно не может быть воспринят промежуточным прокси как HTTP-запрос.
**Наложение маски требует дополнительных ресурсов, поэтому протокол WebSocket не требует её.**
Если по этому протоколу связываются два клиента (не обязательно браузеры), доверяющие друг другу и посредникам, то можно поставить бит `Маска` в `0`, и тогда ключ маски не указывается.
## Пример
Рассмотрим прототип чата на WebSocket и Node.JS.
HTML: посетитель отсылает сообщения из формы и принимает в `div`
```html
<!-- форма для отправки сообщений -->
<form name="publish">
<input type="text" name="message">
<input type="submit" value="Отправить">
</form>
<!-- здесь будут появляться входящие сообщения -->
<div id="subscribe"></div>
```
Код на клиенте:
```js
// создать подключение
var socket = new WebSocket("ws://localhost:8081");
// отправить сообщение из формы publish
document.forms.publish.onsubmit = function() {
var outgoingMessage = this.message.value;
socket.send(outgoingMessage);
return false;
};
// обработчик входящих сообщений
socket.onmessage = function(event) {
var incomingMessage = event.data;
showMessage(incomingMessage);
};
// показать сообщение в div#subscribe
function showMessage(message) {
var messageElem = document.createElement('div');
messageElem.appendChild(document.createTextNode(message));
document.getElementById('subscribe').appendChild(messageElem);
}
```
Серверный код можно писать на любой платформе. В нашем случае это будет Node.JS, с использованием модуля [ws](https://github.com/websockets/ws):
```js
var WebSocketServer = new require('ws');
// подключенные клиенты
var clients = {};
// WebSocket-сервер на порту 8081
var webSocketServer = new WebSocketServer.Server({
port: 8081
});
webSocketServer.on('connection', function(ws) {
var id = Math.random();
clients[id] = ws;
console.log("новое соединение " + id);
ws.on('message', function(message) {
console.log('получено сообщение ' + message);
for (var key in clients) {
clients[key].send(message);
}
});
ws.on('close', function() {
console.log('соединение закрыто ' + id);
delete clients[id];
});
});
```
Рабочий пример можно скачать: [websocket.zip](websocket.zip). Понадобится поставить два модуля: `npm install node-static && npm install ws`.
## Итого
WebSocket -- современное средство коммуникации. Кросс-доменное, универсальное, безопасное.
На текущий момент он работает в браузерах IE10+, FF11+, Chrome 16+, Safari 6+, Opera 12.5+. В более старых версиях FF, Chrome, Safari, Opera есть поддержка черновых редакций протокола.
Там, где вебсокеты не работают -- обычно используют другие транспорты, например `IFRAME`. Вы найдёте их в других статьях этого раздела.
Есть и готовые библиотеки, реализующие функционал COMET с использованием сразу нескольких транспортов, из которых вебсокет имеет приоритет. Как правило, библиотеки состоят из двух частей: клиентской и серверной.
Например, для Node.JS одной из самых известных библиотек является [Socket.IO](http://socket.io).
К недостаткам библиотек следует отнести то, что некоторые продвинутые возможности WebSocket, такие как двухсторонний обмен бинарными данными, в них недоступны. С другой -- в большинстве случаев стандартного текстового обмена вполне достаточно.
========
In this simple example, the bufferedAmount attribute is used to ensure that updates are sent either at the rate of one update every 50ms, if the network can handle that rate, or at whatever rate the network can handle, if that is too fast.
var socket = new WebSocket('ws://game.example.com:12010/updates');
socket.onopen = function () {
setInterval(function() {
if (socket.bufferedAmount == 0)
socket.send(getUpdateData());
}, 50);
};
The bufferedAmount attribute can also be used to saturate the network without sending the data at a higher rate than the network can handle, though this requires more careful monitoring of the value of the attribute over time.
=====
https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent

View file

@ -0,0 +1,48 @@
const Koa = require('koa');
const app = new Koa();
const ws = require('ws');
const Router = require('koa-router');
let router = new Router();
const wss = new ws.Server({noServer: true})
router.get('/hello', handleWebsocket(hello));
function handleWebsocket(handler) {
return async (ctx, next) => {
const upgradeHeader = (ctx.request.headers.upgrade || '').split(',').map(s => s.trim())
// console.log(`websocket middleware called on route ${ctx.path}`);
// console.log(ctx.request.headers);
// console.log(upgradeHeader);
if (upgradeHeader.includes('websocket')) {
ctx.respond = false;
wss.handleUpgrade(ctx.req, ctx.request.socket, Buffer.alloc(0), handler);
} else {
await next();
}
};
}
async function hello(ws) {
ws.on('message', function (message) {
let name = message.match(/\w+$/) || "Guest";
ws.send(`Hello, ${name}!`);
setTimeout(() => ws.close(1000, "Bye!"), 5000);
});
}
app
.use(router.routes())
.use(router.allowedMethods());
if (!module.parent) {
http.createServer(app.callback()).listen(8080);
} else {
exports.accept = app.callback();
}

Binary file not shown.