diff --git a/materials/03-http/03-http.pdf b/materials/03-http/03-http.pdf
new file mode 100644
index 0000000..637d8e9
Binary files /dev/null and b/materials/03-http/03-http.pdf differ
diff --git a/materials/03-http/readme.md b/materials/03-http/readme.md
new file mode 100644
index 0000000..1f0f231
--- /dev/null
+++ b/materials/03-http/readme.md
@@ -0,0 +1,11 @@
+## 3. Протокол HTTP, REST и веб-сервисы.
+
+### Лекция
+
+- [Презентация](03-http.pdf)
+- См. также материалы в конце
+
+### Семинар
+
+- [Материалы семинара](seminar/readme.md)
+- [Пример WebSocket-сервера](seminar/websockets/README.md) — факультативный пример, который не разбирался на семинаре.
diff --git a/materials/03-http/seminar/readme.md b/materials/03-http/seminar/readme.md
new file mode 100644
index 0000000..dda6b9b
--- /dev/null
+++ b/materials/03-http/seminar/readme.md
@@ -0,0 +1,172 @@
+# Семинар 3. HTTP на практике
+
+На семинаре мы проследили путь HTTP-запроса: от `curl`, браузера или Python-клиента через Nginx до Flask-приложения и обратно. Ниже — основные понятия и способы повторить демонстрации на [стенде](website/docker-compose.yaml).
+
+## HTTP: запрос и ответ
+
+**Запрос** содержит метод, адрес ресурса (URI), версию HTTP, заголовки и, при необходимости, тело. **Ответ** содержит версию HTTP, код состояния, заголовки и тело. Пустая строка отделяет заголовки от тела.
+
+На запущенном стенде выполните:
+
+```bash
+curl -v http://localhost:8080/const
+```
+
+В подробном выводе `curl` строки с `>` относятся к запросу, с `<` — к ответу, а с `*` описывают работу самого клиента. `/const` возвращает заранее заданный текст непосредственно из Nginx.
+
+Например, при `curl -4sv http://localhost:8080/` на запущенном стенде получили такой фрагмент вывода:
+
+```text
+> GET / HTTP/1.1
+> Host: localhost:8080
+> User-Agent: curl/8.7.1
+> Accept: */*
+>
+< HTTP/1.1 200 OK
+< Server: nginx/1.31.6
+< Content-Type: text/html; charset=utf-8
+< Content-Length: 17
+< Connection: keep-alive
+< X-Upstream: 172.18.0.2:5002
+<
+Hello from app v2
+```
+
+Здесь Nginx передал запрос одному из Flask-серверов и вернул его ответ клиенту. `X-Upstream` показывает адрес выбранного сервера внутри сети Docker; при другом запуске адрес, версия приложения и версия Nginx могут отличаться. Флаги `-4` и `-s` в примере убирают попытку соединиться по IPv6 и индикатор прогресса, а `-v` показывает обмен запросом и ответом.
+
+Заголовки, которые разбирали на занятии:
+
+| Заголовок | Где смотреть | Что показывает |
+| --- | --- | --- |
+| `Host` | Запрос | К какому сайту или приложению обращается клиент, в том числе когда один адрес и порт обслуживают несколько сайтов. |
+| `User-Agent` | Запрос | Сведения о клиенте, например `curl`. |
+| `Accept` | Запрос | Какие форматы ответа клиент готов принять. |
+| `Accept-Encoding` | Запрос браузера | Какие способы сжатия ответа клиент поддерживает. |
+| `Server` | Ответ | Какой сервер отправил ответ; в демонстрации через proxy виден Nginx. |
+| `Content-Type` | Ответ | Формат тела ответа, например обычный текст, HTML или JSON. |
+| `Connection: keep-alive` | Ответ в примере | TCP-соединение можно использовать повторно, не устанавливая его заново перед каждым запросом. |
+
+**Браузер тоже HTTP-клиент.** Во вкладке **Network** в DevTools найдите запрос к `/const` и сравните его заголовки и ответ с выводом `curl`. Браузер может посылать дополнительные заголовки: два клиента не обязаны формировать запрос одинаково.
+
+## HTTP запросы из кода
+
+На примере Python-библиотеки `requests` разобрали GET-запрос, query-параметры, timeout и проверку статуса.
+
+- **Query-параметры** — пары «ключ — значение» после `?` в URI. Передавайте их через аргумент `params`, а не собирайте URI вручную: символ `&` внутри значения иначе можно принять за разделитель параметров.
+- `timeout=(1, 3)` в показанном примере задаёт 1 секунду на установление соединения и 3 секунды на ожидание данных при чтении ответа. Второе число — не общий предел времени на получение всего ответа: пока сервер регулярно присылает данные, запрос может длиться дольше. Без timeout вызов зависимого сервиса может ждать слишком долго.
+- `raise_for_status()` помогает обнаружить ответ с ошибочным HTTP-статусом и обработать его в коде.
+- Если после timeout или ошибки зависимого сервиса нужных данных нет, приложение может вернуть заранее предусмотренный **fallback**. На семинаре обсуждали fallback-текст и запасную картинку кота.
+
+## Что происходит в Flask-приложении
+
+В [приложении стенда](website/kittens/app.py) Flask связывает пути `/` и `/kittens` с функциями-обработчиками через `@app.route(...)`.
+
+- `/` возвращает текст с версией приложения: `v1` или `v2`.
+- `/kittens` запрашивает JSON у внешнего Cat API, извлекает URL изображения и подставляет его в [HTML-шаблон](website/kittens/templates/index.html) через `render_template`.
+- Получив HTML, браузер делает **ещё один HTTP-запрос** за самой картинкой. Оба обращения можно увидеть в DevTools Network.
+
+Этот пример не рассматривали как готовое production-приложение. Исходящий запрос к Cat API сделан без явного timeout и проверки HTTP-статуса. Если внешний сервис зависнет, ответит ошибкой или пришлёт неожиданные данные, нужно обработать сбой. В качестве возможного ответа на семинаре предложили заранее сохранённую картинку.
+
+## Как устроен демонстрационный стенд
+
+[Docker Compose](website/docker-compose.yaml) запускает два экземпляра Flask-приложения и Nginx. Запись `порт_хоста:порт_контейнера` означает, что слева указан порт для обращения с вашей машины, справа — порт внутри контейнера.
+
+| Сервис | Внутри контейнера | С вашей машины | Назначение |
+| --- | --- | --- | --- |
+| `server1` | `5001` | `9001` | Flask-приложение с `APP_VERSION=v1`. |
+| `server2` | `5002` | `9002` | То же приложение с `APP_VERSION=v2`. |
+| `proxy` | `80` | `8080` | Nginx: proxy, redirect и `/const`. |
+| `proxy` | `10000` | `8081` | Nginx: раздача файлов из `static`. |
+
+Compose подключает [конфигурацию Nginx](website/nginx.conf) и каталог `static` внутрь контейнера `proxy`. Суффикс `:ro` у файла конфигурации задаёт доступ только для чтения.
+
+Чтобы поднять стенд, перейдите из корня репозитория в каталог с `docker-compose.yaml`. Понадобятся работающий Docker и команда `docker compose`:
+
+```bash
+cd materials/03-http/seminar/website
+docker compose up --build -d
+docker compose ps
+```
+
+`up --build` собирает образ Flask-приложения и запускает сервисы; `-d` оставляет контейнеры работать в фоне. `docker compose ps` показывает их состояние. Современный Compose может предупредить, что поле `version` в файле устарело; это предупреждение не мешает запуску. Закончив с примерами, остановите и удалите контейнеры стенда командой `docker compose down` из того же каталога.
+
+
+Что такое Docker и как в стенде связаны порты
+
+Docker запускает приложения в контейнерах — изолированных окружениях с нужными зависимостями. Образ Flask-приложения собирается по [Dockerfile](website/kittens/Dockerfile), а для Nginx Compose использует готовый образ `nginx`. Compose запускает три контейнера, задаёт их переменные окружения и публикует нужные порты на вашей машине.
+
+```text
+Ваша машина Контейнеры
+localhost:9001 ── 9001:5001 ──▶ server1:5001 (Flask v1)
+localhost:9002 ── 9002:5002 ──▶ server2:5002 (Flask v2)
+localhost:8080 ── 8080:80 ──▶ proxy:80 (Nginx)
+ └─▶ server1:5001 или server2:5002
+localhost:8081 ── 8081:10000 ─▶ proxy:10000 (файлы из static)
+```
+
+Слева от `:` в Compose указан порт вашей машины, справа — порт контейнера. Когда Nginx обращается к `server1:5001` или `server2:5002`, он использует внутренние адреса сервисов, а не порты `9001` и `9002` вашей машины.
+
+
+
+## Возможности Nginx и файл его конфигурации
+
+В этом стенде Nginx принимает запросы как единая точка входа, распределяет их между копиями приложения, возвращает redirect и сам отдаёт текст или файлы. Какую из этих задач выполнять, определяет его [конфигурация](website/nginx.conf).
+
+**Reverse proxy** принимает запрос клиента и сам обращается к backend. В группе **upstream** стенда перечислены `server1:5001` и `server2:5002`. Для `/` и `/kittens` Nginx выбирает один из них и добавляет в ответ `X-Upstream` с адресом выбранного backend.
+
+Если один backend перестаёт отвечать, Nginx временно исключает его из балансировки и направляет новые запросы к оставшимся живым репликам. При ошибке соединения он может попробовать другую реплику и для текущего запроса, поэтому отказ одного сервера не обязательно приводит к ошибке у клиента.
+
+Когда мы несколько раз отправили запрос к `/` через Nginx, он по очереди направил запросы к двум Flask-серверам: ответы `v1` и `v2` чередовались. Так мы увидели **round-robin** — простой алгоритм выбора backend по очереди. Для трёх серверов порядок мог бы выглядеть так: `3 → 1 → 2 → 3 → 1 → 2`. Алгоритм не оценивает, сколько работы потребует конкретный запрос. Если серверы различаются по мощности или сетевой задержке, равное число запросов может дать им разную нагрузку; в обсуждении упоминали веса backend-серверов. При этом клиент обращается к одной точке входа — Nginx — и не выбирает реплику сам.
+
+При **redirect** Nginx возвращает клиенту код 3xx и заголовок `Location` с новым URI. Следующий запрос по этому URI делает уже клиент; при reverse proxy Nginx обращается к backend сам. Правило `/search/` на стенде перенаправляет запрос в поиск Google: браузер следует переходу автоматически, а `curl` можно передать флаг `-L`. Как прикладной случай обсудили перенаправление со старого URI `/promotions` на новый `/discounts`.
+
+Кроме проксирования и redirect, Nginx сам отдаёт текст по `/const` и статические файлы через порт `8081`. Для каталога `static` включён `autoindex`, поэтому в браузере можно переходить по его содержимому.
+
+## Как повторить примеры
+
+Команды ниже рассчитаны на запущенный [стенд](website/docker-compose.yaml). `localhost` означает машину, на которой опубликованы порты контейнеров. Флаг `-i` показывает заголовки ответа.
+
+```bash
+# Посмотреть запрос, ответ и заголовки.
+curl -v http://localhost:8080/const
+
+# Обратиться напрямую к двум экземплярам Flask-приложения.
+curl -i http://localhost:9001/
+curl -i http://localhost:9002/
+
+# Обратиться через Nginx; повторите команду и сравните ответ и X-Upstream.
+curl -i http://localhost:8080/
+
+# Посмотреть redirect на поиск Google, затем пройти по нему.
+curl -i http://localhost:8080/search/cats
+curl -L http://localhost:8080/search/cats
+
+# Получить файл, который отдаёт сам Nginx.
+curl -i http://localhost:8081/greetings.txt
+```
+
+Ещё два опыта удобно провести в браузере: откройте `http://localhost:8080/const` и сравните его запрос с `curl` во вкладке Network; затем откройте `http://localhost:9001/kittens` и найдите отдельную загрузку изображения. `/kittens` зависит от внешнего Cat API, а переход на Google — от доступности Google.
+
+## API заказов: набросок контракта
+
+В конце занятия мы наметили операции интернет-магазина: создать заказ, получить его данные, изменить адрес или другую часть заказа. Для создания предложили `POST`, для чтения — `GET`, для частичного изменения — `PATCH`; в зависимости от контракта приложение может использовать `PUT`. Упомянули и системы, где для упрощения чтение реализуют через `POST` с телом запроса, но не представляли это как универсальное правило.
+
+В результате можно предложить следующий контракт:
+
+| Операция | Запрос | Успешный ответ | Почему так |
+| --- | --- | --- | --- |
+| Создать заказ | `POST /api/orders` с данными заказа в JSON | `201 Created`, заголовок `Location` с URI заказа | Запрос отправляют коллекции заказов; сервер обрабатывает данные и создаёт новый заказ с собственным ID. |
+| Получить заказ | `GET /api/orders/{id}` | `200 OK` и данные заказа в JSON | Клиент обращается к уже известному заказу по его ID и читает данные, не создавая новую сущность. |
+| Частично изменить заказ | `PATCH /api/orders/{id}` с JSON, например с новым адресом | `200 OK` и обновлённый заказ в JSON | Клиент указывает существующий заказ и передаёт только те данные, которые нужно изменить. |
+| Удалить заказ | `DELETE /api/orders/{id}` | `204 No Content` | Клиент указывает конкретный заказ, который нужно удалить; тело успешного ответа не требуется. |
+
+Если заказ не найден, для чтения, изменения или удаления предусмотрен `404 Not Found`; если данные для создания неверны — ошибка клиента, например `400 Bad Request`. Для долгой обработки допускается отдать `202 Accepted` и отдельный ресурс операции.
+
+### Повторы запросов и идемпотентность
+
+`GET` только читает заказ, поэтому его повтор не создаёт новую сущность. С `POST /api/orders` иначе: если клиент не получил ответ и повторил запрос, он может случайно создать ещё один заказ. Сервису нужно понять, относится ли повтор к той же логической операции. Для этого можно использовать токен идемпотентности: повтор с тем же токеном сервис распознаёт и не создаёт второй заказ. На семинаре обсудили два способа получить такой идентификатор.
+
+- **Ключ создаёт клиент.** Например, фронтенд генерирует токен и передаёт его в заголовке или в JSON-теле запроса. Сервис сохраняет токен, чтобы определить возможные повторы запросов. Если ключ нужен лишь на время нескольких попыток и позже клиент его не воспроизводит, хранить его бессрочно незачем: подойдёт кэш на стороне сервиса с ограниченным сроком жизни, например несколько минут. Важно, чтобы на протяжении этого срока повторные попытки запроса приходили с тем же ключом.
+- **Ключ создаёт сервис.** Если предметная область такова, что у сущности имеется некоторый уникальный идентификатор (например, `order.id` в случае создания заказа), то сервис может использовать этот ID как основу ключа идемпотентности. Поскольку `order.id` скорее всего хранится в базе вместе с заказом, такой ключ идемпотентности может жить столько же, сколько запись о заказе; отдельное короткое время жизни, как у временного ключа в кэше, ему не обязательно.
+
+Таким образом, выбор способа и срока хранения ключа идемпотентности зависит от контракта API и предметной области.
diff --git a/materials/03-http/seminar/website/docker-compose.yaml b/materials/03-http/seminar/website/docker-compose.yaml
new file mode 100644
index 0000000..5a0bee8
--- /dev/null
+++ b/materials/03-http/seminar/website/docker-compose.yaml
@@ -0,0 +1,30 @@
+version: '3'
+
+services:
+ server1:
+ build:
+ context: ./kittens
+ environment:
+ APP_VERSION: 'v1'
+ BIND_PORT: 5001
+ ports:
+ - 9001:5001
+
+ server2:
+ build:
+ context: ./kittens
+ environment:
+ APP_VERSION: 'v2'
+ BIND_PORT: 5002
+ ports:
+ - 9002:5002
+
+ proxy:
+ image: nginx
+ ports:
+ - 8080:80
+ - 8081:10000
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./static:/static
+ restart: unless-stopped
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/kittens/.dockerignore b/materials/03-http/seminar/website/kittens/.dockerignore
new file mode 100644
index 0000000..cb57075
--- /dev/null
+++ b/materials/03-http/seminar/website/kittens/.dockerignore
@@ -0,0 +1,3 @@
+docker-compose.yaml
+Dockerfile
+__pycache__
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/kittens/Dockerfile b/materials/03-http/seminar/website/kittens/Dockerfile
new file mode 100644
index 0000000..0e1c6bc
--- /dev/null
+++ b/materials/03-http/seminar/website/kittens/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.11.5-slim-bullseye
+
+WORKDIR /application
+
+# Copy the requirements file and download the dependencies.
+COPY requirements.txt requirements.txt
+RUN pip3 install -r requirements.txt
+
+# Copy other data.
+COPY . .
+
+CMD [ "python3", "app.py"]
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/kittens/app.py b/materials/03-http/seminar/website/kittens/app.py
new file mode 100644
index 0000000..719d3c2
--- /dev/null
+++ b/materials/03-http/seminar/website/kittens/app.py
@@ -0,0 +1,45 @@
+from flask import Flask, render_template, request
+
+import logging
+import os
+import requests
+
+app = Flask(__name__)
+app.logger.setLevel(logging.INFO)
+
+APP_VERSION = os.getenv('APP_VERSION')
+BIND_HOST = os.getenv('BIND_HOST') or '0.0.0.0'
+BIND_PORT = os.getenv('BIND_PORT') or '8000'
+
+
+@app.before_request
+def log_request():
+ app.logger.info(
+ 'method=%s path=%s host=%s remote_addr=%s',
+ request.method, request.path, request.host, request.remote_addr,
+ )
+
+
+@app.route('/')
+def home():
+ return f'Hello from app {APP_VERSION}'
+
+
+@app.route('/kittens')
+def kittens():
+ # Get a URL to a random kitten photo.
+ response = requests.get('https://api.thecatapi.com/v1/images/search?api_')
+
+ try:
+ # Response structure is as follows:
+ # [{"id":"bL3lrUi1A","url":"ex.com/bL3lrUi1A.jpg","width":1280,"height":720}]
+ data = response.json()
+ kitten_url = data[0]['url']
+
+ return render_template('index.html', kitten_url=kitten_url)
+ except Exception as e:
+ return f'Failed to fetch a kitten image :(\n {e}'
+
+
+if __name__ == '__main__':
+ app.run(host=BIND_HOST, port=BIND_PORT)
diff --git a/materials/03-http/seminar/website/kittens/requirements.txt b/materials/03-http/seminar/website/kittens/requirements.txt
new file mode 100644
index 0000000..a4884d4
--- /dev/null
+++ b/materials/03-http/seminar/website/kittens/requirements.txt
@@ -0,0 +1,2 @@
+Flask==2.3.3
+requests==2.28.2
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/kittens/templates/index.html b/materials/03-http/seminar/website/kittens/templates/index.html
new file mode 100644
index 0000000..f42173f
--- /dev/null
+++ b/materials/03-http/seminar/website/kittens/templates/index.html
@@ -0,0 +1,8 @@
+
+
+ DistSys kittens
+
+
+
+
+
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/nginx.conf b/materials/03-http/seminar/website/nginx.conf
new file mode 100644
index 0000000..482d891
--- /dev/null
+++ b/materials/03-http/seminar/website/nginx.conf
@@ -0,0 +1,49 @@
+events {}
+
+http {
+ access_log /var/log/nginx/access.log;
+ error_log /var/log/nginx/error.log;
+
+ gzip on;
+
+ server {
+ listen 80;
+ server_name distsys.baliuk.me;
+
+ location /kittens {
+ proxy_pass http://backends;
+ add_header X-Upstream $upstream_addr;
+ }
+
+ location = / {
+ proxy_pass http://backends;
+ add_header X-Upstream $upstream_addr;
+ }
+
+ location /search/ {
+ rewrite ^/search/(.*)$ https://google.com/search?q=$1 redirect;
+ }
+
+ location = /const {
+ add_header Content-Type text/plain;
+ return 200 "Any preset text!";
+ }
+ }
+
+ upstream backends {
+ server server1:5001;
+ server server2:5002;
+ }
+
+ server {
+ listen 10000;
+
+ autoindex on; # enable directory listing output
+ autoindex_exact_size off; # output file sizes rounded to kilobytes, megabytes, and gigabytes
+ autoindex_localtime on; # output local times in the directory
+
+ location / {
+ root /static;
+ }
+ }
+}
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/static/greetings.txt b/materials/03-http/seminar/website/static/greetings.txt
new file mode 100644
index 0000000..2d30f35
--- /dev/null
+++ b/materials/03-http/seminar/website/static/greetings.txt
@@ -0,0 +1 @@
+Hello, distributed systems!
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/static/img/http-request.svg b/materials/03-http/seminar/website/static/img/http-request.svg
new file mode 100644
index 0000000..44a424a
--- /dev/null
+++ b/materials/03-http/seminar/website/static/img/http-request.svg
@@ -0,0 +1,127 @@
+
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/static/img/http-response.svg b/materials/03-http/seminar/website/static/img/http-response.svg
new file mode 100644
index 0000000..4fb310e
--- /dev/null
+++ b/materials/03-http/seminar/website/static/img/http-response.svg
@@ -0,0 +1,121 @@
+
\ No newline at end of file
diff --git a/materials/03-http/seminar/website/static/img/scaleway-websockets.webp b/materials/03-http/seminar/website/static/img/scaleway-websockets.webp
new file mode 100644
index 0000000..881aa26
Binary files /dev/null and b/materials/03-http/seminar/website/static/img/scaleway-websockets.webp differ
diff --git a/materials/03-http/seminar/websockets/README.md b/materials/03-http/seminar/websockets/README.md
new file mode 100644
index 0000000..d96b660
--- /dev/null
+++ b/materials/03-http/seminar/websockets/README.md
@@ -0,0 +1,32 @@
+# websockets
+
+В данной директории находится простая реализация websocket сервера на Go.
+
+## Управление
+
+Запуск сервера: `go run .`.
+
+Для подключения к серверу предлагается использовать утилиту `wscat`:
+```bash
+wscat --connect 'localhost:8000/connect?author=igor'
+# В параметре author указываем наш никнейм
+```
+
+Далее можно отправить JSON сообщение с полем `text`, после чего это сообщение будет доставлено всем
+клиентам сервера:
+```bash
+~ wscat --connect 'localhost:8000/connect?author=igor'
+> {"text": "privet"}
+< {"Text":"privet","Author":"igor","SendTime":"2024-09-24T01:49:50.623671+03:00"}
+```
+
+## Бонус
+
+Чтобы открыть свой сервер, запущенный локально, всему интернету,
+можно использовать ngrok:
+```
+ngrok http 8000
+```
+
+После выполнения вам будет выдан публичный домен, по которому будет доступен
+порт на вашей локальной системе.
\ No newline at end of file
diff --git a/materials/03-http/seminar/websockets/go.mod b/materials/03-http/seminar/websockets/go.mod
new file mode 100644
index 0000000..d5b1426
--- /dev/null
+++ b/materials/03-http/seminar/websockets/go.mod
@@ -0,0 +1,8 @@
+module ws
+
+go 1.20
+
+require (
+ github.com/google/uuid v1.3.1
+ github.com/gorilla/websocket v1.5.0
+)
diff --git a/materials/03-http/seminar/websockets/go.sum b/materials/03-http/seminar/websockets/go.sum
new file mode 100644
index 0000000..931bdcf
--- /dev/null
+++ b/materials/03-http/seminar/websockets/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
+github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
diff --git a/materials/03-http/seminar/websockets/main.go b/materials/03-http/seminar/websockets/main.go
new file mode 100644
index 0000000..26a9069
--- /dev/null
+++ b/materials/03-http/seminar/websockets/main.go
@@ -0,0 +1,118 @@
+package main
+
+/*
+Connect client 1:
+
+ wscat --connect 'localhost:8000/connect?author=lupa'
+
+Connect client 2:
+
+ wscat --connect 'localhost:8000/connect?author=lupa'
+Message: {"Text": "hi there"}
+*/
+
+import (
+ "log"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
+)
+
+type MessageStream struct {
+ Text string
+ Author string
+ SendTime *time.Time
+}
+
+type MessengerServer struct {
+ history chan *MessageStream
+ toSend map[string]chan *MessageStream
+}
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+}
+
+func (s *MessengerServer) Connect(w http.ResponseWriter, r *http.Request) {
+ // Extract author from the URL query parameters
+ author := r.URL.Query().Get("author")
+ if author == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("failed to upgrade: %s\n", err)
+ return
+ }
+
+ id := uuid.New()
+ s.toSend[id.String()] = make(chan *MessageStream, 1000)
+ log.Printf("Connected: %v\n", id)
+
+ go func() {
+ for {
+ var msg MessageStream
+ err := conn.ReadJSON(&msg)
+ if err != nil {
+ log.Println("Failed to decode a message:", err)
+ return
+ }
+
+ log.Printf("Received from %s: %v\n", author, msg)
+
+ now := time.Now()
+ msg.SendTime = &now
+ msg.Author = author
+ s.history <- &msg
+ }
+ }()
+
+ for {
+ mes := <-s.toSend[id.String()]
+ err := conn.WriteJSON(mes)
+ if err != nil {
+ log.Printf("Deleted %v stream, sending error: %v", id.String(), err)
+ delete(s.toSend, id.String())
+
+ return
+ }
+
+ log.Printf("Sent to stream %v: %v\n", id, mes)
+ }
+}
+
+func (s *MessengerServer) fanout() {
+ for {
+ mes := <-s.history
+
+ log.Printf("Got from history: %v", mes)
+
+ for _, ch := range s.toSend {
+ ch <- mes
+ }
+ }
+}
+
+func main() {
+ port := os.Getenv("MESSENGER_SERVER_PORT")
+ if port == "" {
+ port = "8000"
+ log.Println("Missing MESSENGER_SERVER_PORT, using default value: " + port)
+ }
+ server := MessengerServer{
+ history: make(chan *MessageStream, 1000),
+ toSend: make(map[string]chan *MessageStream),
+ }
+
+ http.HandleFunc("/connect", server.Connect)
+
+ go server.fanout()
+
+ log.Fatal(http.ListenAndServe("0.0.0.0:"+port, nil))
+}