---
title: "WebSocket в Echo: Upgrader, hub и hijack соединения"
description: "echo websocket: апгрейд через gorilla Upgrader поверх c.Response(), http.Hijacker, hub для broadcast задач, токен в query и shutdown hijacked-соединений."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/18-websocket/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# WebSocket в Echo: Upgrader, hub и hijack соединения

*Курс «Echo: минималистичный быстрый фреймворк», урок 18 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/18-websocket/*

echo websocket — тема, которую семнадцатый урок про RESTful-практики оставил за кадром намеренно: REST живёт циклом «запрос — ответ — соединение закрыто», а сегодня мы это соединение впервые не закроем. Апгрейдим обычный GET до WebSocket прямо внутри echo-хендлера, и вся правая часть этого трюка — c.Response(), c.Request() — окажется на три строки длиннее, чем в мире fiber, где contrib-пакет спрятал апгрейд за фабрикой. Сверяемся с сырыми исходниками: response.go v4.15.0 покажет, почему *echo.Response подходит на роль http.ResponseWriter для Upgrader, и каким образом echo через http.NewResponseController реализует Hijack; серверный файл gorilla/websocket v1.5.3 — какие проверки переживает рукопожатие и что отвечает клиенту на каждую ошибку; док-комментарий http.Server.Shutdown — почему graceful shutdown обходит hijacked-соединения стороной, и что делать вам. Протокол целиком — кадры, маскирование, контрол-опкоды, heartbeat — мы уже разобрали в [уроке fiber про WebSocket](/courses/fiber/lessons/16-websocket/), здесь он понадобится ровно настолько, насколько echo его касается. Зато построим мы больше, чем эхо-сервер: hub с register/unregister/broadcast, прилетает в комнату каждое новое задание из POST /api/v1/tasks, токен едет в query-параметре, потому что браузерному WebSocket заголовок Authorization не установить. Приступаем — канон на :1323 обзаводится вторым протоколом.

### echo websocket: почему REST-канону тесно

Восемнадцать уроков канона жили по одной схеме: запрос пришёл, хендлер собрал ответ, echo записал его в Response и закрыл соединение. Для задач, форм, вложений и кук — идеально. Но класс задач на этом ломается: клиент должен узнавать об изменениях без опроса, а сервер — держать канал в обе стороны. Когда второй пользователь создаёт задачу через POST /api/v1/tasks, ваша вкладка со списком задач об этом не знает — пока не дёрнет listTasks снова. WebSocket закрывает ровно это: после специального HTTP-рукопожатия соединение не умирает, а превращается в постоянный двунаправленный канал с собственным форматом кадров поверх TCP.

Запрос «websocket go» выдаёт тысячи примеров на gorilla/websocket и net/http — и echo здесь честный середняк: собственного ws-слоя в фреймворке нет, cookbook предлагается брать gorilla и склеивать его с контекстом руками. Это не слабость, а последовательность философии из первого урока: echo — тонкая типизированная обёртка над net/http, и всё, что net/http умеет отдавать наружу, echo отдаёт как есть. В fiber мы собирали ту же конструкцию через contrib-фабрику websocket.New — [урок fiber про WebSocket](/courses/fiber/lessons/16-websocket/) разобрал её от заголовков апгрейда до комнат; сегодня та же цель, но инструмент — голый Upgrader поверх c.Response() и c.Request(), и вы своими глазами увидите каждый шаг, который в fiber был спрятан.

План урока трёхслойный. Первый — транспорт: что *echo.Response умеет такого, чего не умеет обычный http.ResponseWriter-обёрточный слой, и что делает Hijack. Второй — протокол на стороне сервера: Upgrader из gorilla с его проверками, ошибками и 101 Switching Protocols, написанный в response-файле literally байтовый ответ. Третий — практика: эхо-эндпоинт на /api/v1/ws, аутентификация запросом, который не умеет заголовки, hub для broadcast задач и shutdown, который hijacked-соединения не трогает — проверено по доке net/http.

### c.Response() как http.ResponseWriter: поле Writer и два интерфейса

Upgrader.Upgrade из gorilla требует два аргумента: http.ResponseWriter и *http.Request. В хендлере fiber их добывали через c.Context() и c.Request() — с оговорками, потому что fasthttp-обёртка подменяет стёк. В echo всё проще: c.Response() возвращает *echo.Response, а c.Request() — обычный *http.Request, и первый вопрос урока — почему echo.Response можно передать в Upgrade напрямую, хотя сигнатура ждёт http.ResponseWriter.

Ответ читается в response.go v4.15.0: *echo.Response реализует интерфейс http.ResponseWriter — методы Header, Write, WriteHeader на месте — плюс два расширяющих интерфейса, http.Flusher и http.Hijacker. Второй и есть ключ к апгрейду: внутри gorilla идёт тип-ассершн h, ok := w.(http.Hijacker), и если обёртка не умеет Hijack — рукопожатие упадёт с ошибкой «websocket: response does not implement http.Hijacker». У echo.Response такой метод есть, и он целиком делегирован в стандартную библиотеку через http.NewResponseController — современный (Go 1.20+) способ добраться до Flush и Hijack сквозь любые слои обёрток.

Дальше интереснее. После апгрейда gorilla пишет 101 и все кадры МИНУЯ echo.Response: она делает h.Hijack(), забирает сырой net.Conn и общается с сокетом напрямую. echo об этом узнаёт только постфактум: поле Status остаётся нулевым, Committed — false, Size — нулевым, и любой хендлер ниже по цепочке, который попробует что-то записать через c.JSON, столкнётся с обёрткой, считающей ответ не начатым. Что при этом произойдёт в net/http — разберём в отдельном predict, потому что там спрятана самая коварная ловушка урока. Но сначала — сырые строки response.go.

### response.go v4.15.0: Writer, WriteHeader, Hijack

```go
// response.go, v4.15.0 — *echo.Response
// Response wraps an http.ResponseWriter and implements its interface to be used
// by an HTTP handler to construct an HTTP response.
type Response struct {
    Writer      http.ResponseWriter
    echo        *Echo
    beforeFuncs []func()
    afterFuncs  []func()
    Status      int
    Size        int64
    Committed   bool
}

// WriteHeader sends an HTTP response header with status code. If WriteHeader is
// not called explicitly, the first call to Write will trigger an implicit
// WriteHeader(http.StatusOK). Thus explicit calls to WriteHeader are mainly
// used to send error codes.
func (r *Response) WriteHeader(code int) {
    if r.Committed {
        r.echo.Logger.Warn("response already committed")
        return
    }
    r.Status = code
    for _, fn := range r.beforeFuncs {
        fn()
    }
    r.Writer.WriteHeader(r.Status)
    r.Committed = true
}

// Flush implements the http.Flusher interface to allow an HTTP handler to flush
// buffered data to the client.
func (r *Response) Flush() {
    err := http.NewResponseController(r.Writer).Flush()
    if err != nil && errors.Is(err, http.ErrNotSupported) {
        panic(fmt.Errorf("echo: response writer %T does not support flushing (http.Flusher interface)", r.Writer))
    }
}

// Hijack implements the http.Hijacker interface to allow an HTTP handler to
// take over the connection.
func (r *Response) Hijack() (net.Conn, *bufio.ReadWriter, error) {
    return http.NewResponseController(r.Writer).Hijack()
}

// Unwrap returns the original http.ResponseWriter.
// ResponseController can be used to access the original http.ResponseWriter.
func (r *Response) Unwrap() http.ResponseWriter {
    return r.Writer
}
```

Четыре факта, из которых складывается весь урок. Первый: Writer — экспортируемое поле http.ResponseWriter, и после апгрейда сырой сокет не прячется в приватные поля — echo просто больше не владеет им. Второй: WriteHeader охраняется флагом Committed — повторный вызов не паникует, а пишет Warn «response already committed» и выходит; это самая громкая строка файла, и она ещё выстрелит. Третий: Hijack реализует сигнатуру http.Hijacker — (net.Conn, *bufio.ReadWriter, error) — и делегирует в http.NewResponseController(r.Writer).Hijack(): ResponseController пробирается сквозь обёртки по цепочке Unwrap, и тот факт, что у echo.Response метод Unwrap есть, гарантирует, что оригинальный writer из net/http будет найден. Четвёртый: Flush через ResponseController паникует с текстом «echo: response writer %T does not support flushing», если нижний writer не умеет Flusher — для WebSocket это неважно, но ровно на том же механизме стоит следующий урок про SSE, запомните панику. Заметка про историю: в старых выпусках echo Response имел приватное поле writer и собственную логику Hijack; начиная с v4.13 механизм переведён на ResponseController, и v4.15.0 — та версия, по которой мы сверяемся.

### Hijack: что именно забирает gorilla у net/http

Hijack — это передача владения сокетом от http.Server вашему хендлеру. Сервер до этого момента ведёт соединение: парсит запрос, выставляет таймауты, управляет keep-alive, умеет записать ответ. Hijack возвращает net.Conn и *bufio.ReadWriter — буферизованную пару поверх сокета, в которой, возможно, ещё лежат непрочитанные байты, — и с этого момента сервер перестаёт что-либо делать с соединением: не закроет, не завершит по таймауту, не узнает о его смерти.

Gorilla проверяет пару вещей сразу после Hijack, и обе — про буфер, который сервер оставил после себя. Первая: brw.Reader.Buffered() > 0 — клиент умудрился отправить данные до завершения рукопожатия, и gorilla закрывает сокет с ошибкой «websocket: client sent data before handshake is complete». Вторая — оптимизация: если собственный буфер чтения сервера достаточно велик, gorilla переиспользует его как буфер соединения, чтобы не аллоцировать лишний. Всё это — детали, которых вы не увидите, пока не прочитаете server.go, а ошибка «client sent data before handshake» в проде выглядит загадочно ровно до этого момента.

И последнее свойство hijacked-соединения, которое формулирует сама net/http: сервер «does not even know about» такие соединения — док-комментарий Server.Close говорит это буквально: «Close does not attempt to close (and does not even know about) any hijacked connections, such as WebSockets». Не знает — значит не закроет ни при Close, ни при Shutdown. К чему это приводит при остановке приложения — отдельная секция урока, а пока перейдём к тому, кто и в каком порядке проверяет сам апгрейд.

### Проверьте себя: Hijacker на *echo.Response

**Вопрос:** gorilla выполняет тип-ассершн h, ok := w.(http.Hijacker) на том объекте, который вы передали как первый аргумент Upgrade. Вы вызвали upgrader.Upgrade(c.Response(), c.Request(), nil). Что вернёт ассершн?

- ✅ ok=true: у *echo.Response есть метод Hijack() (net.Conn, *bufio.ReadWriter, error) — реализация через http.NewResponseController
- ok=false: echo.Response — лишь обёртка, Hijack есть только у исходного http.ResponseWriter из net/http
- ok=true, но Hijack вернёт ошибку: echo запрещает hijack, пока Committed=false
- Панику: тип-ассершн на интерфейсе, который не реализован, паникует всегда

Метод Hijack из response.go имеет ровно ту сигнатуру, которую требует интерфейс http.Hijacker, — значит, *echo.Response ему удовлетворяет, и ассершн проходит с ok=true. Реализация — return http.NewResponseController(r.Writer).Hijack(): ResponseController умеет доставать Hijack из вложенных обёрток через Unwrap, и echo.Response его экспортирует. Если бы метода не было, ok был бы false и gorilla вернула бы HandshakeError с текстом «websocket: response does not implement http.Hijacker» — не паника, а честная ошибка, потому что ассершн в gorilla двухзначный. Само echo ничего против hijack не имеет: наоборот, Hijack — публичная часть Response, и запрещать её было бы нельзя, иначе echo не смог бы вообще работать с WebSocket.

### server.go v1.5.3: Upgrader целиком

```go
// server.go, gorilla/websocket v1.5.3
// Upgrader specifies parameters for upgrading an HTTP connection to a
// WebSocket connection.
//
// It is safe to call Upgrader's methods concurrently.
type Upgrader struct {
    // HandshakeTimeout specifies the duration for the handshake to complete.
    HandshakeTimeout time.Duration

    // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If
    // a buffer size is zero, then buffers allocated by the HTTP server are
    // used. The I/O buffer sizes do not limit the size of the messages that
    // can be sent or received.
    ReadBufferSize, WriteBufferSize int

    // WriteBufferPool is a pool of buffers for write operations. If the value
    // is not set, then write buffers are allocated to the connection for the
    // lifetime of the connection.
    WriteBufferPool BufferPool

    // Subprotocols specifies the server's supported protocols in order of
    // preference.
    Subprotocols []string

    // Error specifies the function for generating HTTP error responses. If
    // Error is nil, then http.Error is used to generate the HTTP response.
    Error func(w http.ResponseWriter, r *http.Request, status int, reason error)

    // CheckOrigin returns true if the request Origin header is acceptable. If
    // CheckOrigin is nil, then a safe default is used: return false if the
    // Origin request header is present and the origin host is not equal to
    // request Host header.
    //
    // A CheckOrigin function should carefully validate the request origin to
    // prevent cross-site request forgery.
    CheckOrigin func(r *http.Request) bool

    // EnableCompression specify if the server should attempt to negotiate per
    // message compression (RFC 7692).
    EnableCompression bool
}
```

Восемь полей — и каждое станет строчкой продакшн-конфига в конце урока, но три стоят разбора сразу. HandshakeTimeout ставит дедлайн на весь рукопожатный обмен: gorilla выставит write deadline на сокет и снимет его после записи 101. ReadBufferSize и WriteBufferSize управляют аллокациями, и док честно снимает главный миф: размеры буферов НЕ ограничивают размер сообщений — за это отвечает отдельный SetReadLimit, о котором fiber-урок уже говорил. CheckOrigin — ваш единственный барьер против cross-site апгрейда: если поле nil, работает дефолт «origin есть и не равен Host — отказ», а док-комментарий отдельно предупреждает: «A CheckOrigin function should carefully validate the request origin to prevent cross-site request forgery». Строка «It is safe to call Upgrader's methods concurrently» разрешает один общий Upgrader на всё приложение — заводите его пакетной переменной, а не в хендлере, это дешевле и безопаснее для буферов.

### Проверки Upgrade: что и на что отвечает сервер

| Проверка | Статус | Ошибка в HandshakeError |
| --- | --- | --- |
| Connection содержит токен upgrade | 400 | websocket: the client is not using the websocket protocol: 'upgrade' token not found in 'Connection' header |
| Upgrade содержит токен websocket | 400 | websocket: the client is not using the websocket protocol: 'websocket' token not found in 'Upgrade' header |
| Метод запроса GET | 405 | websocket: the client is not using the websocket protocol: request method is not GET |
| Sec-WebSocket-Version = 13 | 400 | websocket: unsupported version: 13 not found in 'Sec-WebSocket-Version' header |
| Origin принят CheckOrigin | 403 | websocket: request origin not allowed by Upgrader.CheckOrigin |
| Sec-WebSocket-Key — base64 от 16 байт | 400 | websocket: not a websocket handshake: 'Sec-WebSocket-Key' header must be Base64 encoded value of 16-byte in length |
| writer реализует http.Hijacker | 500 | websocket: response does not implement http.Hijacker |

### 101 Switching Protocols: ответ, написанный вручную

Если все проверки пройдены, gorilla делает то, чего от HTTP-фреймворка ждать непривычно: собирает ответ как сырую строку байтов и пишет её в сокет, минуя обычный механизм ответа. В server.go это один append: префикс "HTTP/1.1 101 Switching Protocols", затем "Upgrade: websocket", "Connection: Upgrade", вычисленный Sec-WebSocket-Accept — хеш от клиентского ключа, знакомый вам по fiber-уроку, — затем субпротокол и расширения, затем всё из переданного responseHeader, затем пустая строка. Два согласования протокола echo в этой строке не участвуют вообще: к моменту записи сокет уже hijacked, echo.Response ничего не знает о 101.

Отсюда два правила, которые новичкам кажутся странными. Первое: после успешного Upgrade нельзя отвечать HTTP-методами — ни c.JSON, ни return echo.NewHTTPError, ни WriteHeader. Ответ на этот запрос уже целиком отправлен, и это не HTTP-ответ в смысле echo. Второе: строка netConn.SetDeadline(time.Time{}) сразу после сборки ответа — gorilla снимает таймауты, которые http.Server навесил на соединение, иначе keep-alive-дедлайны сервера убили бы долгоживущий ws-канал через минуты. Сокет начинает новую жизнь вне юрисдикции net/http.

И ещё одна граница — между ошибкой рукопожатия и успехом. Провал любой проверки из таблицы выше — это нормальный HTTP-ответ: gorilla сама пишет статус и текст через returnError, а хендлер получает (*Conn)(nil) и ошибку HandshakeError. То есть если клиент прислал не тот заголовок, вы вправе вернуть ошибку из хендлера — echo залогирует её как обычную 4xx. Всё, что после успешного апгрейда, — уже не HTTP, и правила игры меняются немедленно.

### Прогноз: обычный GET на ws-маршрут

```go
// маршрут: v1.GET("/ws", serveEcho)
// клиент: curl -H "Authorization: Bearer dev-token-42" localhost:1323/api/v1/ws

func serveEcho(c echo.Context) error {
    ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
    if err != nil {
        return err
    }
    defer ws.Close()
    // ...
}
```

**Что выведет программа?**

- ✅ curl получит 400, тело «Bad Request» и заголовок Sec-Websocket-Version: 13, а хендлер вернёт HandshakeError
- curl получит 101 Switching Protocols: curl — полноценный ws-клиент
- Хендлер запустит вечный цикл ReadMessage и зависнет: curl не закроет соединение
- echo вернёт 500: Upgrade не смог привести c.Response() к http.ResponseWriter

У curl в запросе нет ни заголовков Upgrade/Connection, ни Sec-WebSocket-Key, поэтому Upgrade проваливается на самой первой проверке таблицы: 'upgrade' token not found in 'Connection' header. returnError вызывается с http.StatusBadRequest, ставит Sec-Websocket-Version: 13 в заголовки и пишет http.Error(w, http.StatusText(400), 400) — curl видит классическую 400 с телом «Bad Request». Хендлер же возвращает HandshakeError с тем же текстом дальше по цепочке — это ошибка, echo её обработает как обычную HTTP-ошибку; запомните: провал рукопожатия — ещё HTTP-мир. Вариант «500 из-за ассершна» неверен по разбору quiz-а: Hijacker на *echo.Response есть. А зависание невозможно: до успешного апгрейда соединение остаётся обычным HTTP-запросом, и его судьбу решает ответ сервера, а не цикл чтения ws.

### Канон: ws-эндпоинт /api/v1/ws рядом с задачами

```go
// main.go — апгрейд через echo-контекст: эхо-сервер на /api/v1/ws
var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        return r.Host == "localhost:1323" // в проде — белый список origin-ов
    },
}

// браузерный WebSocket не умеет ставить заголовок Authorization,
// поэтому токен едет в query: ws://localhost:1323/api/v1/ws?token=dev-token-42
func wsAuth(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        if c.QueryParam("token") != "dev-token-42" {
            return echo.NewHTTPError(http.StatusUnauthorized, "нужен ?token=")
        }
        c.Set("user", "alice") // как bearerAuth из восьмого урока
        return next(c)
    }
}

func serveEcho(c echo.Context) error {
    ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
    if err != nil {
        return err // HandshakeError: ответ 4xx уже отправлен returnError-ом
    }
    defer ws.Close()
    ws.SetReadLimit(4096) // потолок входящего сообщения

    for {
        mt, msg, err := ws.ReadMessage()
        if err != nil {
            return nil // close/ping/pong обрабатывает библиотека, цикл завершён
        }
        if mt == websocket.TextMessage {
            out := append([]byte("echo: "), msg...)
            if err := ws.WriteMessage(websocket.TextMessage, out); err != nil {
                return nil // писать в мёртвый сокет нечестно — выходим
            }
        }
    }
}

// регистрация рядом с задачами:
//   go hub.run() // скоро
//   v1.GET("/ws", serveEcho, wsAuth)
//   v1.POST("/tasks", createTask)
//
// wscat -c "ws://localhost:1323/api/v1/ws?token=dev-token-42"
// > привет
// < echo: привет
```

Три строки интеграции — upgrader.Upgrade(c.Response(), c.Request(), nil) — и вы в ws-мире; всё остальное — gorilla, знакомый по fiber-уроку: SetReadLimit против гигабайтных кадров, ReadMessage, возвращающий только сообщения данных, обработка ошибок чтения как сигнала к выходу. Обратите внимание на wsAuth: полноценный bearerAuth из восьмого урока здесь не годится не потому, что сломан, а потому, что браузерному WebSocket API нельзя указать произвольный заголовок Authorization — токен приходится класть в query-параметр, а это значит, что он попадёт в логи прокси и access-логи, и в проде токен для ws обязан быть краткоживущим. Middleware при этом остаётся обычным echo-слоем: wsAuth вписан третьим аргументом v1.GET и работает до хендлера, до апгрейда — отказ 401 уходит как обычный JSON-конверт, а не как ws-ошибка. CheckOrigin здесь сравнивает r.Host с localhost:1323: Host — заголовок запроса, Origin — то, откуда запущена страница; на локалке совпадают, в проде список origin-ов придётся вести явно. И да — переменная out, а не echo: затирать имя пакета внутри его же курса — плохая примета.

### Ошибка после 101: что произойдёт, если хендлер не промолчал

В коде выше после успешного апгрейда хендлер возвращает nil во всех ветках — и это не перестраховка, а точное следствие механики. Проследим, что случится, если вернуть echo.NewHTTPError(http.StatusInternalServerError, "упало") из хендлера, чьё соединение уже апгрейднуто. e.ServeHTTP получает ошибку и зовёт e.HTTPErrorHandler(err, c); тот формирует JSON-конверт и вызывает c.JSON(500, ...) — то есть WriteHeader(500) на *echo.Response.

А теперь самое интересное: Committed у echo.Response всё ещё false. gorilla писала 101 напрямую в сокет через Hijack — echo-обёртку она не касалась, поэтому WriteHeader(500) пройдёт свою внутреннюю проверку и вызовет r.Writer.WriteHeader(500) на исходном http.ResponseWriter из net/http. И вот тут в игру вступает защита стандартной библиотеки: в server.go net/http метод WriteHeader начинается с проверки — if w.conn.hijacked() { ... logf("http: response.WriteHeader on hijacked connection from %s (%s:%d)") ... return }. Запись отклоняется, в лог уходит предупреждение с местом вызова, а телу вообще некуда ехать: Write на hijacked-соединении возвращает 0 и ErrHijacked с той же записью в лог.

Итог спокойный, но шумный: ни падения, ни порчи ws-канала — соединение продолжает жить, и его кадры идут как ни в чём не бывало, зато каждое такое событие мусорит лог строчкой «response.WriteHeader on hijacked connection». В тестах и на локалке это безобидно, в проде — сигнал, что хендлер перепутал миры. Правило урока: после успешного Upgrade хендлер возвращает nil или ошибку только для лога, но никогда не рассчитывает, что кто-то отправит по ней HTTP-ответ.

### Проверьте себя: возвращённая ошибка после апгрейда

**Вопрос:** Хендлер после успешного upgrader.Upgrade вернул echo.NewHTTPError(500, "oops"). Что вы увидите?

- ✅ В логе «http: response.WriteHeader on hijacked connection from ...», ws-соединение живёт, клиент ничего не получил
- Клиент получит HTTP-ответ 500, а ws-соединение закроется
- echo.Logger.Warn «response already committed» и ответ не уйдёт
- Панику: писать в hijacked-соединение запрещено

Цепочка: HTTPErrorHandler → c.JSON(500) → echo.Response.WriteHeader(500) → Committed=false, поэтому echo-варнинг НЕ сработает (это ловушка варианта три — он был бы верен при повторном WriteHeader на echo.Response) — дальше r.Writer.WriteHeader(500), а там net/http видит conn.hijacked() и пишет в лог «http: response.WriteHeader on hijacked connection from ...», на чём всё заканчивается: return без записи. Тело ответа поехало бы в Write — но Write на hijacked-соединении возвращает ErrHijacked и логирует аналогично, так что клиент не получит ничего, а ws-соединение продолжает обрабатывать кадры. Паники нет ни на одном уровне: стандартная библиотека намеренно превращает ошибку в лог, потому что хендлеры, забывающие про hijack, — штатная ситуация для WebSocket-мира. Единственный способ получить echo-варнинг «response already committed» — дважды вызвать WriteHeader/c.JSON на НЕ hijacked-соединении, например в тестах.

### Hub: комната ws-клиентов для задач канона

Эхо-сервер красив, но бесполезен: он отвечает только тому, кто спросил. Настоящая потребность канона — уведомлять всех открытых вкладки /tasks о том, что кто-то создал задачу. Классическая конструкция для этого называется hub, и эталонная версия лежит прямо в gorilla — examples/chat/hub.go, тот самый файл, который fiber-урок упоминал вскользь. Эго структура — четыре поля: клиенты картой, три канала: register, unregister, broadcast. Единый цикл run() в отдельной горутине select-ит их и исполняет единственные операции над картой — добавление, удаление, рассылку. Ни одно другое место программы не трогает clients напрямую: синхронизация достигается не мьютексом, а владением данными одной горутиной — тот же принцип, что мы проходили в базовом курсе про каналы, только здесь он защищает живые сокеты.

Рассылка в эталоне содержит гениальную деталь против медленных клиентов: send у клиента — БУФЕРИЗОВАННЫЙ канал, и в цикле broadcast стоит select с веткой default: если буфер переполнен — клиент слишком медленный, и его не ждут, а исключают (close(client.send) и delete). Прогноз ниже заставит вас проследить это до конца. Вторая деталь — удаление: unregister-ветка проверяет наличие клиента в карте перед close, потому что close закрытого канала — паника, и hub обязан быть идемпотентным к двойным unregister-ам.

Для канона мы адаптируем эталон без секретов: клиенты — *websocket.Conn, broadcast-канал типизируем Task — новые задачи из POST /api/v1/tasks будут уезжать всем подписчикам. Роли строго разделены: хендлер ws-маршрута ТОЛЬКО читает из сокета, hub ТОЛЬКО пишет — закон одного читателя и одного писателя из fiber-урока соблюдён архитектурно, без мьютексов.

### Канон: hub задач на :1323

```go
// hub.go — register/unregister/broadcast по мотивам gorilla examples/chat
type Hub struct {
    clients    map[*websocket.Conn]bool
    broadcast  chan Task            // входящие события: новые задачи
    register   chan *websocket.Conn
    unregister chan *websocket.Conn
}

func newHub() *Hub {
    return &Hub{
        clients:    make(map[*websocket.Conn]bool),
        broadcast:  make(chan Task),
        register:   make(chan *websocket.Conn),
        unregister: make(chan *websocket.Conn),
    }
}

func (h *Hub) run() {
    for {
        select {
        case ws := <-h.register:
            h.clients[ws] = true
        case ws := <-h.unregister:
            if _, ok := h.clients[ws]; ok {
                delete(h.clients, ws)
            }
        case t := <-h.broadcast:
            data, _ := json.Marshal(t)
            for ws := range h.clients {
                // буфер send-канала из эталона; здесь пишем напрямую,
                // ошибка = клиент мёртв: исключаем, не ждём
                if err := ws.WriteMessage(websocket.TextMessage, data); err != nil {
                    delete(h.clients, ws)
                    ws.Close()
                }
            }
        }
    }
}

// serveEcho меняет одну строку: при подключении клиент встаёт в очередь
//   hub.register <- ws
//   defer func() { hub.unregister <- ws }()

func createTask(c echo.Context) error {
    var t Task
    if err := c.Bind(&t); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
    }
    t.ID = nextID()
    tasks = append(tasks, t)
    hub.broadcast <- t // событие уезжает всем открытым ws-клиентам
    return c.JSON(http.StatusCreated, t)
}
```

run() — дословный select из hub.go gorilla: register добавляет в карту, unregister удаляет с проверкой наличия (защита от двойного close — в эталоне ещё и close(client.send), который у нас не нужен, потому что send-канала нет), broadcast сериализует Task и рассылает. Разница с эталоном одна и принципиальна: gorilla пишет через буферизованный send-канал каждого клиента, а у нас WriteMessage зовётся из run() напрямую — это честный упрощённый вариант при условии, что больше НИКТО не пишет в эти сокеты: serveEcho после апгрейда только читает. Если обе роли нужны — возвращайте send-канал с writePump-ом, как в эталоне и fiber-уроке. createTask остаётся обычным REST-хендлером семнадцатого урока: 201 и JSON — плюс одна строка hub.broadcast <- t; блокирующий канал без буфера — осознанный выбор: пока рассылка не закончена, POST не ответит, и две createTask не перемешают рассылки. Запуск: hub := newHub(); go hub.run() до e.Start.

### Прогноз: медленный клиент на broadcast

```go
// вариант hub из эталона gorilla (examples/chat/hub.go):
case message := <-h.broadcast:
    for client := range h.clients {
        select {
        case client.send <- message:
            // буфер send-канала: send = make(chan []byte, 256)
        default:
            close(client.send)
            delete(h.clients, client)
        }
    }

// клиент A читает свои сообщения, клиент B завис (сеть умерла без close).
// в broadcast пришло 300 сообщений подряд. что произойдёт с B?
```

**Что выведет программа?**

- ✅ B сначала накопит 256 сообщений в буфере, на переполнении его исключат: close и delete
- B получит все 300: run() блокируется на каждой отправке и ждёт B
- B исключат сразу на первом же сообщении: select с default всегда уходит в default
- Панику: писать в один send-канал из цикла нельзя

Буферизованный канал send ёмкостью 256 из эталона client.go принимает сообщения без блокировки, пока буфер не заполнится: первые 256 уходят B «в карман». Как только буфер полон, ветка case client.send <- message перестаёт быть готовой, select берёт default — и клиент исключается: close(client.send) разбудит его writePump (тот увидит ошибку от записи в закрытый канал и закроет соединение), delete убирает его из рассылки. Вариант «блокируем всех» — ровно то, что происходило бы без буфера и default: один зависший клиент замораживал бы broadcast для всех, и это та самая ловушка, ради которой эталон так устроен. Вариант «default всегда» путает семантику select: default берётся только когда НИ ОДНА коммуникация не готова. Паники нет: отправки идут из одной горутины run() последовательно. Наш упрощённый hub из прошлого блока решает ту же проблему грубее: ошибка WriteMessage у зависшего клиента удаляет его — но только когда TCP наконец заметит смерть, а буфер эталона спасает рассылку до этого момента.

### Соберите run() из эталона

Восстановите единый цикл hub: каждый канал — своя ветка, удаление — с проверкой наличия, рассылка — без ожидания медленных клиентов.

```go
func (h *Hub) run() {
    for {
        select {
        case client := <-h.___:
            h.clients[client] = true
        case client := <-h.___:
            if _, ok := h.clients[client]; ok {
                delete(h.clients, client)
                close(client.send)
            }
        case message := <-h.___:
            for client := range h.clients {
                select {
                case client.send <- message:
                ___:
                    close(client.send)
                    delete(h.clients, client)
                }
            }
        }
    }
}
```

Подсказка: Имена каналов — из структуры Hub gorilla; ветка, куда уходит переполненный клиент, в select называется default.

Правильные ответы в пропусках: `register`, `unregister`, `broadcast`, `default`

### Соберите путь запроса до первого кадра

Расставьте события по порядку: от GET-запроса браузера до первого сообщения в канале — маршрутизация до апгрейда, токен до Upgrade, кадры после.

Правильный порядок строк:

1. `браузер: new WebSocket("ws://localhost:1323/api/v1/ws?token=dev-token-42") — GET с заголовками Upgrade и Sec-WebSocket-Key`
2. `роутер echo находит v1.GET("/ws", serveEcho, wsAuth) — это обычная маршрутизация GET`
3. `wsAuth проверяет c.QueryParam("token") — 401 уходит обычным JSON, апгрейд не начинался`
4. `serveEcho вызывает upgrader.Upgrade(c.Response(), c.Request(), nil)`
5. `gorilla проверяет заголовки и Origin, делает Hijack у *echo.Response — echo получает net.Conn в собственность`
6. `gorilla пишет в сокет «HTTP/1.1 101 Switching Protocols» и снимает дедлайны сервера`
7. `serveEcho делает hub.register <- ws — клиент в карте рассылки`
8. `serveEcho входит в цикл ws.ReadMessage() — кадры данных и контрол-кадры обрабатывает библиотека`
9. `вкладка закрывается: ReadMessage вернёт ошибку close — defer сработает и hub.unregister <- ws`

### Graceful shutdown: hijacked-соединения никто не закроет

Канон на :1323 останавливают через e.Shutdown(ctx) — обёртку над http.Server.Shutdown, как выстроено в fiber-курсе и будет в echo-уроке про деплой. Для обычных HTTP-соединений Shutdown идеален: перестать принимать новые, дождаться текущих, выйти. Но у нас теперь есть класс соединений, о которых док-комментарий Server.Shutdown говорит прямо: «Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets. The caller of Shutdown should separately notify such long-lived connections of shutdown and wait for them to close, if desired. See [Server.RegisterOnShutdown] for a way to register shutdown notification functions».

Переводим на наш случай: после e.Shutdown(ctx) HTTP-трафик уйдёт корректно, а ws-клиенты останутся подключенными к мёртвому процессу — и процесс не завершится, пока их кто-то не закроет, потому что горутины с ReadMessage всё ещё живы. Стандартная библиотека даёт ровно один крючок: Server.RegisterOnShutdown(fn) — функция будет вызвана при Shutdown. Echo хранит http.Server публично, e.Server, так что после e := echo.New() доступно e.Server.RegisterOnShutdown(...).

Для hub-канона схема получается трёхходовая: регистрируем коллбек, который закрывает канал done; в run() добавляем ветку case <-done: закрыть все conn-ы и опустошить карту; в serveEcho после закрытия сокета ReadMessage вернёт ошибку, хендлер выйдет, unregister-строка defer-ом почистит карту — и горутины остановятся. Теперь Shutdown дожидается всех: и HTTP-запросов, и ws-клиентов, получивших честное уведомление. Без этой ветки у вас будет классический симптом: e.Shutdown повис до контекстного дедлайна, хотя обычных запросов давно нет.

### Канон: выключение с уведомлением ws-клиентов

```go
// main.go — shutdown, который умеет hijacked-соединения
func main() {
    e := echo.New()
    e.Use(middleware.RequestID(), middleware.Recover())

    hub := newHub()
    done := make(chan struct{})
    e.Server.RegisterOnShutdown(func() { close(done) }) // крючок из net/http
    go hub.run(done)

    // ... маршруты: v1.GET("/ws", serveEcho, wsAuth), v1.POST("/tasks", createTask)

    go func() {
        if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed {
            e.Logger.Fatal(err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := e.Shutdown(ctx); err != nil {
        e.Logger.Error(err)
    }
}

// в run() появляется ветка:
//   case <-done:
//       for ws := range h.clients {
//           ws.Close()
//           delete(h.clients, ws)
//       }
```

Порядок важен: RegisterOnShutdown вызывается до e.Start, иначе Shutdown пройдёт без коллбека — проверьте это в своих проектах, ошибка тихая. close(done) в коллбеке разбудит все select-ы run()-ов; закрытие сокетов ws.Close() даст ReadMessage в каждом хендлере ошибку — их горутины выйдут, defer-ы отправят unregister (получившему close(done) hub уже всё равно — карта пуста, а мёртвые register-каналы просто не прочитаются; для идеальной чистоты serveEcho перед unregister-ом проверяет select на done). Ветку err != http.ErrServerClosed при e.Start тоже держите: без неё штатная остановка падает в Logger.Fatal — ErrServerClosed сервер возвращает всегда, это сигнал нормального завершения Serve. Дедлайн 10 секунд — страховка: если какой-то ws-клиент игнорирует закрытие и горутина не выходит, процесс всё равно завершится по ctx.

### Продакшн-минимум: таймауты, буферы, wss и прокси

Соберём конфиг Upgrader, который переживёт не только локалку. HandshakeTimeout: 10 * time.Second — рукопожатие обязано завершаться, иначе полуоткрытые соединения копятся. ReadBufferSize/WriteBufferSize: дефолтные нули используют буферы сервера — норм для начала; WriteBufferPool типа BufferPool экономит аллокации, когда клиентов много, а записей в меру, и заводится один на приложение — док-комментарий прямо требует уникальный пул на каждое значение размера буфера. SetReadLimit — не поле Upgrader, а вызов на соединении, но без него endpoint не считается готовым: 4096 в каноне, дальше — close с кодом 1009, знакомым по fiber-уроку. EnableCompression — permessage-deflate с server_no_context_takeover; включайте осознанно: сжатие меж доменных ws-соединений в прошлом было источником атак BREACH-семейства.

Транспорт: wss:// — тот же ws поверх TLS. На локалке достаточно ws://localhost, в проде TLS терминирует прокси, и апгрейд должен доехать до echo нетронутым — заголовки Upgrade и Connection — hop-by-hop, их обязан переписать каждый прокси; nginx-конфигурация для этого разобрана в fiber-уроке про деплой и вернётся в двадцать четвёртом уроке этого курса, когда мы будем деплоить echo — там же проверим, что прокси не буферизует и не рвёт долгоживущие соединения.

И последний штрих, ради которого стоит перечитать Upgrader: Subprotocols — список поддерживаемых субпротоколов «in order of preference»; клиент в заголовке Sec-WebSocket-Protocol предлагает свои, сервер выбирает первый пересекающийся. Если ваш ws-канал несёт не JSON-чат, а конкретный протокол — скажем, graphql-ws — именно здесь место договорённости, а не в каждом сообщении.

> **Ловушки WebSocket в echo.** Первая: обычный GET на ws-маршрут — не ошибка приложения, а HandshakeError с 400/403/405; не стройте на нём retry-логику клиента — браузер повторит тот же GET. Вторая: возврат echo.NewHTTPError после успешного апгрейда — строчка «http: response.WriteHeader on hijacked connection» в логе и тишина для клиента; хендлер после 101 молчит. Третья: e.Shutdown не закроет ws-соединения — RegisterOnShutdown с close(done) и ветка в run() обязательны, иначе процесс висит. Четвёртая: bearerAuth недоступен браузерному WebSocket — токен в query попадает в access-логи; для прода выпускайте краткоживущий одноразовый токен специально для апгрейда. Пятая: без SetReadLimit гигабайтный кадр честно соберётся в памяти; без CheckOrigin(nil по умолчанию пропускает только same-origin, но ваш фронт на другом порту уже не пройдёт) — либо добавляйте origin в белый список, либо отключайте проверку там, где клиент не браузер и Origin-заголовка нет вовсе.

### Карточки: WebSocket в echo

- **Интеграция Upgrader с echo-контекстом** — upgrader.Upgrade(c.Response(), c.Request(), nil): *echo.Response реализует http.ResponseWriter (Header/Write/WriteHeader) и http.Hijacker (Hijack() через http.NewResponseController(r.Writer).Hijack(), v4.13+, Unwrap отдаёт оригинальный writer). Провал рукопожатия — нормальный HTTP-ответ 400/403/405 от returnError + HandshakeError хендлеру.
- **Что делает Upgrade после проверок** — Hijack: забирает net.Conn и *bufio.ReadWriter у http.Server (Buffered() > 0 — ошибка «client sent data before handshake is complete»), пишет в сокет сырую строку «HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ...», снимает дедлайны netConn.SetDeadline(time.Time{}). echo-обёртку минует: Status/Committed остаются нулевыми.
- **Проверки рукопожатия server.go v1.5.3** — Connection=upgrade (400), Upgrade=websocket (400), метод GET (405), Sec-WebSocket-Version=13 (400), CheckOrigin (403 «request origin not allowed by Upgrader.CheckOrigin»), Sec-WebSocket-Key — base64 16 байт (400), http.Hijacker (500). badHandshake = «websocket: the client is not using the websocket protocol: ». Дефолт CheckOrigin: origin есть и Host не равен — отказ.
- **Ошибка после успешного апгрейда** — HTTPErrorHandler → WriteHeader → net/http: «http: response.WriteHeader on hijacked connection from ...» и return; Write вернёт 0 и ErrHijacked с такой же записью. echo-варнинг «response already committed» НЕ сработает: Committed=false, gorilla писала мимо echo. Правило: после 101 хендлер возвращает nil (или ошибку только для лога).
- **Hub из gorilla examples/chat** — clients map[*Client]bool + три канала: register, unregister, broadcast; run() в одной горутине select-ит все ветки. Рассылка — select с default: буфер send (256) переполнен → close(client.send) и delete — медленный клиент выкидывается, рассылка не блокируется. Unregister с проверкой наличия — защита от двойного close. Роли: хендлер только читает, пуш только пишет.
- **Shutdown и hijacked-соединения** — Док Server.Shutdown: «Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets» — notify отдельно через Server.RegisterOnShutdown. Схема канона: close(done) в коллбеке → ветка case <-done в run() закрывает все conn → ReadMessage возвращает ошибку → хендлеры выходят → defer-ы шлют unregister. e.Server публичен, RegisterOnShutdown ставится ДО e.Start. ErrServerClosed от e.Start — норма, не Fatal.

### Что дальше: поток в одну сторону

Канон научился разговаривать в обе стороны: апгрейд через Upgrader поверх c.Response() с честным Hijack, handshake-таблица с семью проверками, hub, куда прилетают новые задачи из POST /api/v1/tasks, и shutdown, который hijacked-соединения уведомляет, а не бросает. Все строки сверены с response.go v4.15.0, server.go gorilla v1.5.3 и server.go net/http.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите канон go run main.go, откройте две вкладки с клиентом и убедитесь, что задача, созданная в одной, уезжает в обе.

Следующий, девятнадцатый урок — Server-Sent Events: тот же результат «сервер знает раньше клиента», но без апгрейда, без hijack и только в одну сторону. c.Stream и c.Response() с Flush, heartbeat, именованные события task.created и reconnect с Last-Event-ID — и вы сможете осознанно выбирать между ws и SSE вместо случайного выбора. Навигация по курсу — на [странице курса Echo](/courses/echo/); группы и версии, внутри которых живёт ws-маршрут, — в [уроке про группы](/courses/echo/lessons/11-gruppy/); слои, которые ws-запрос проходит как обычный GET, — в [уроке про кастомные middleware](/courses/echo/lessons/08-kastomnye-middleware/).

## Коротко: что запомнить

- Echo не имеет своего ws-слоя: берём gorilla/websocket и зовём upgrader.Upgrade(c.Response(), c.Request(), nil). *echo.Response реализует http.ResponseWriter и http.Hijacker — Hijack() (net.Conn, *bufio.ReadWriter, error) делегирован в http.NewResponseController(r.Writer).Hijack() (response.go v4.15.0, механизм v4.13+ с Unwrap).
- Провал рукопожатия — обычный HTTP-мир: returnError пишет 400/403/405 (таблица семи проверок server.go v1.5.3) и ставит Sec-Websocket-Version: 13, хендлер получает HandshakeError. badHandshake = «websocket: the client is not using the websocket protocol: ».
- Успешный апгрейд: Hijack забирает сокет, gorilla пишет 101 сырыми байтами и снимает дедлайны сервера — echo об этом не знает: Committed=false. Ошибка из хендлера после 101 даст в лог «http: response.WriteHeader on hijacked connection», клиент не получит ничего; правило — после 101 возвращать nil.
- Браузерный WebSocket не может ставить заголовок Authorization: токен уезжает в query (?token=...) и попадает в логи — для прода краткоживущий одноразовый токен. wsAuth — обычный echo-слой до апгрейда; ws-запрос проходит через весь middleware-стек как GET.
- Hub (эталон gorilla examples/chat): клиенты картой, каналы register/unregister/broadcast, один run() select-ом; рассылка через буферизованный send с веткой default — медленный клиент исключается close+delete, рассылка не блокируется. В каноне: broadcast типизирован Task, createTask шлёт hub.broadcast <- t, хендлер ws только читает.
- Shutdown: «Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets» — уведомление через e.Server.RegisterOnShutdown(func(){ close(done) }), ветка case <-done в run() закрывает conn, ReadMessage завершает хендлеры. RegisterOnShutdown — до e.Start; ErrServerClosed от e.Start — норма.
- Продакшн-минимум: HandshakeTimeout, SetReadLimit (без него — память под гигантский кадр), CheckOrigin с белым списком origin-ов (дефолт — same-origin), WriteBufferPool один на приложение, wss через TLS-прокси с переписыванием hop-by-hop заголовков Upgrade/Connection; Subprotocols — «in order of preference».

## Домашнее задание

Соберите канон урока: /api/v1/ws с wsAuth (?token=dev-token-42), эхо-цикл с SetReadLimit(4096), hub задач с broadcast из createTask. Первое: откройте два wscat-подключения и создайте задачу curl-ом — POST /api/v1/tasks с JSON {"title":"..."} и bearer-токеном; убедитесь, что оба ws-клиента получили Task JSON, а curl получил 201. Второе: отправьте в ws сообщение длиннее 4096 байт и поймайте close — проверьте код 1009 и текст ErrReadLimit в логе. Третье: замените CheckOrigin на функцию, разрешающую только Host localhost:1323, и попробуйте апгрейд с wscat без Origin — убедитесь, что он проходит (Origin пуст — checkSameOrigin вернёт true), затем подделайте Origin http://evil.example и получите 403. Четвёртое: добавьте RegisterOnShutdown с close(done) и ветку case <-done в run(); поднимите сервер, подключите два wscat, отправьте SIGTERM и убедитесь, что оба клиента получили close, а процесс завершился быстрее дедлайна ctx. Пятое: верните echo.NewHTTPError(500) из хендлера после апгрейда и найдите в логе строчку «http: response.WriteHeader on hijacked connection» — объясните по server.go net/http, почему клиент не получил ответ.

## Вопросы и ответы по уроку

### Почему в echo нет своего WebSocket-модуля, как у fiber — websocket.New?

Философия echo — минимальная обёртка над net/http, а апгрейд — это пару строк над c.Response()/c.Request(), которые и так доступны. Fiber технологически иначе: fasthttp не совместим с net/http, поэтому contrib-фабрика websocket.New с конфигом там — необходимый мост. На gorilla оба фреймворка дают одинаковый протокол и одинаковые сообщения об ошибках — отличается только точка входа: в echo сигнатуры net/http сохранены, в fiber за апгрейд отвечает стек.

### Можно ли защитить ws-маршрут bearerAuth из восьмого урока?

Для не-браузерных клиентов — да: любой ws-клиент может поставить заголовок Authorization, и bearerAuth сработает как на обычном GET. Для браузера — нет: API WebSocket не позволяет задать заголовки, поэтому токен передают в query-параметре или в Sec-WebSocket-Protocol, и это должен быть отдельный краткоживущий токен (его увидят в access-логах прокси). Правильный гибрид: короткоживущий одноразовый токен в query на апгрейде, дальше сессия — внутри ws-кадров.

### Почему процесс не завершается после e.Shutdown, хотя HTTP-запросов больше нет?

Потому что hijacked-соединения Shutdown не закрывает и не ждёт — но и горутины ваших ws-хендлеров никто не остановил, а процесс живёт, пока живы горутины. Закройте их сами: e.Server.RegisterOnShutdown с сигналом (close(done)), ветка case <-done в hub.run, ws.Close() на всех клиентах — ReadMessage вернёт ошибку, хендлеры выйдут, defer-ы почистят карту. После этого Shutdown вернётся, а не отработает по дедлайну контекста.
