---
title: "Браузерные клиенты (TypeScript)"
description: "Connect из браузера: connect-web, fetch-транспорт, CORS и preflight, TypeScript-клиент из той же схемы, стримы как AsyncIterable и ConnectError."
course: "Connect: современный RPC от Buf"
url: "https://golangmaster.ru/courses/connect/lessons/09-brauzernye/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Браузерные клиенты (TypeScript)

*Курс «Connect: современный RPC от Buf», урок 9 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/connect/lessons/09-brauzernye/*

Connect начинался с простого тезиса: RPC должен работать из браузера без прокси и хаков. Разбираем connect typescript-стек: пакет connect web (@connectrpc/connect-web), fetch-транспорт, генерацию кода через @bufbuild/protoc-gen-es, CORS и preflight-запросы, TypeScript-клиент для того же eliza.proto и стримы как AsyncIterable. Это браузерный клиент rpc, ради которого Connect появился на свет — и круг из первого урока наконец замыкается.

Вспомните, с чего начался курс: в [первом уроке](/courses/connect/lessons/01-vvedenie/) мы говорили, что классический gRPC в браузере не работает — нет доступа к HTTP/2-фреймам и трейлерам, а значит нужен Envoy-прокси или gRPC-Web. Connect возник как ответ именно на эту боль, и настал урок, где мы закрываем круг: напишем фронтенд-клиент на TypeScript, который ходит в наш eliza-сервис из [третьего урока](/courses/connect/lessons/03-pervyj-servis/) напрямую, без прокси. Стек, который в поиске часто описывают словами connectrpc браузер, состоит из трёх частей: @bufbuild/protobuf — рантайм protobuf-типов, @bufbuild/protoc-gen-es — плагин, генерирующий TypeScript из тех же .proto, и @connectrpc/connect-web — браузерные транспорты. Весь этот стек называется коротко connect web, а поисковое имя связки — connect typescript: генерация типов из proto плюс рантайм для браузера. Его главная философия вас уже не удивит: тот же контракт, тот же путь процедуры, те же коды ошибок — только язык другой. TypeScript-клиент говорит по-русски протокола Connect ровно так же, как Go-клиент из [пятого урока](/courses/connect/lessons/05-klienty/): вызов say принимает сообщение, возвращает промис с ответом, а ошибка — знакомый *connect.Error, только в TS-обличии.

### Кодогенерация: protoc-gen-es и buf.gen.yaml

Схема одна — clients разные. К notes и eliza мы уже генерировали Go-код; теперь добавляем TypeScript. Плагин называется @bufbuild/protoc-gen-es и описывается в buf.gen.yaml, который вы заводили во втором уроке: секция plugins получает ещё одну запись, local: protoc-gen-es, с выходом в каталог src/gen и опцией target=ts. Никакого нового файла схемы не нужно — eliza.proto и notes.proto используются как есть, и это принципиальный плюс proto-first: контракт один, а артефактов два. Плагин выпишет по каждому message класс с полями, методами fromJson и toJson, плюс по каждому сервису — объект ElizaService, который в TypeScript-мире играет роль пути и метаданных: из него createClient построит типизированный клиент. Заметьте симметрию с Go: там был пакет notesv1 с типами и notesv1connect с RPC-слоем, здесь всё живёт в одном сгенерированном модуле, но деление то же самое — данные и транспорт. Версии пакетов в npm — @bufbuild/protobuf и @connectrpc/connect-web — держите согласованно: они развиваются парой, и рассинхрон рантайма с генератором даёт самые невнятные ошибки типов.

### buf.gen.yaml: вторая колонка плагинов

```go
version: v2
plugins:
  - local: protoc-gen-go
    out: gen
    opt: paths=source_relative
  - local: protoc-gen-connect-go
    out: gen
  - local: protoc-gen-es
    out: src/gen
    opt: target=ts
```

Тот же buf.gen.yaml из второго урока с добавленной третьей записью: protoc-gen-es пишет TypeScript-модули в src/gen. Команда не меняется — buf generate; плагин ставится через npm install -D @bufbuild/protoc-gen-es, и buf найдёт его в node_modules. Схема остаётся одна: Go-сервер и TS-фронтенд генерируются из одного eliza.proto, и никаких ручных синхронизаций между ними не существует.

### Транспорт: createConnectTransport на fetch

В Go клиенту нужен был http.Client — в браузере его роль играет fetch-транспорт. createConnectTransport({ baseUrl: 'http://localhost:8080' }) создаёт объект транспорта, который ходит собственным протоколом Connect: Content-Type application/json или application/proto, пути вида /connectrpc.eliza.v1.ElizaService/Say, HTTP-статусы по таблице кодов — всё это вы уже разбирали в [уроке про протоколы](/courses/connect/lessons/04-protokoly/). Есть и второй конструктор — createGrpcWebTransport: тот же клиентский API, но на проводе протокол gRPC-Web с его фреймингом и статусами в трейлерах-заголовках; он нужен, когда бэкенд — классический gRPC-сервер, прикрытый Envoy, или сервер Connect, к которому идёт деплой с gRPC-Web-совместимостью. Выбор транспорта — единственное место, где фронтенд знает о проводе; сам клиент от него не зависит. Транспорт fetch-овой природы означает и все стандартные следствия: куки и заголовки браузер ставит сам, redirect'ы и кеширование работают как в обычных fetch-вызовах, а GET-запросы для процедур с idempotency_level = NO_SIDE_EFFECTS — тот самый GET-кеш из четвёртого урока — кешируются браузерной инфраструктурой по-настоящему.

### Первый вызов из браузера

```go
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { ElizaService } from "./gen/eliza/v1/eliza_pb";

const transport = createConnectTransport({
  baseUrl: "http://localhost:8080",
});

const client = createClient(ElizaService, transport);

export async function ask(sentence: string): Promise<string> {
  const res = await client.say({ sentence });
  return res.sentence;
}
```

Три строки настройки — и typed-клиент готов. createClient принимает сервис-объект из сгенерированного модуля и транспорт, возвращает объект с методом на каждый rpc. Метод say принимает plain-объект запроса (поля совпадают со схемой, camelCase как в protobuf-JSON), возвращает Promise с ответом — await client.say({...}) читается как обычный fetch, но с типами и контрактами из proto. Ошибка отклонённого вызова ловится обычным try/catch — но это не любой exception, а ConnectError, о нём ниже.

**Вопрос:** Почему классический gRPC не работает из браузера напрямую?

- Браузеры не умеют HTTP/2
- ✅ JS-код не имеет доступа к трейлерам и низкоуровневым фреймам HTTP/2
- Protobuf не поддерживается браузерами
- gRPC требует клиентский сертификат в браузере

HTTP/2 браузеры умеют, но только на уровне движка: fetch и XHR не дают доступа к трейлерам ответа и контролю фреймов, а gRPC кладёт код ошибки и статус в grpc-status трейлер. Connect-протокол спроектирован без этой зависимости: статус в HTTP-статусе и теле, поэтому из браузера ходит напрямую. gRPC-Web — обходной путь с трейлерами в конце тела; обоим вариантам посвящён четвёртый урок.

### CORS: preflight и заголовки, которые надо разрешить

Фронтенд живёт на другом origin — и тут в игру входит CORS, политика браузера, которой не существовало в Go-мире из предыдущих уроков. Первый запрос к серверу с чужого origin браузер отправляет не сразу: увидев, что Content-Type — application/json (не простое значение), а значит, запрос не «simple», он сначала шлёт preflight — OPTIONS-запрос с заголовками Access-Control-Request-Method и Access-Control-Request-Headers. Сервер обязан ответить набором Access-Control-Allow-*: origin фронта, методами POST и GET (GET — ради кешируемых процедур), и перечислить разрешённые заголовки: Content-Type плюс клиентские — Connect-Timeout-Ms, Grpc-Timeout, X-User-Agent. Забудете перечислить хоть один — preflight не пройдёт, и браузер даже не попробует отправить настоящий запрос, а в консоли появится знаменитая «CORS policy» ошибка. Для gRPC-Web-транспорта добавляются X-Grpc-Web и expose-заголовки Grpc-Status и Grpc-Message — код ошибки приходит именно в них, и без Access-Control-Expose-Headers клиент увидит сеть сработала, а не реальный код. Сам CORS настраивается один раз в main вашего Go-сервера — веткой на OPTIONS или middleware; дальше все уроки про безопасность и деплой раскрутят тему глубже.

### Минимальный CORS-ответ для Connect-протокола

```go
// В main рядом с mux: отвечаем на preflight сами.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        origin := "http://localhost:3000" // origin фронта
        w.Header().Set("Access-Control-Allow-Origin", origin)
        w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers",
                "Content-Type, Connect-Timeout-Ms, Grpc-Timeout, X-User-Agent")
        if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
        }
        mux.ServeHTTP(w, r) // дальше — обычная маршрутизация
})
```

Один обработчик на корне решает весь CORS для Connect-протокола: ставит три Allow-заголовка, на OPTIONS отвечает 204 и не пускает запрос дальше. Настоящие вызовы идут через тот же mux — eliza-сервис смонтирован внутри. В продакшене origin берут из конфига и валидируют по белому списку, а перечисление заголовков расширяют, когда фронт начнёт слать свои; для gRPC-Web-транспорта список дополняется X-Grpc-Web и Access-Control-Expose-Headers: Grpc-Status, Grpc-Message.

**Вопрос:** Фронтенд на localhost:3000 шлёт первый POST с Content-Type: application/json к серверу на localhost:8080. Что произойдёт раньше самого POST?

- Ничего: POST уйдёт сразу, сервер сам решит, отвечать ли
- ✅ Браузер шлёт OPTIONS preflight и ждёт ответа с Access-Control-Allow-*
- Браузер повторит POST дважды для надёжности
- Запрос уйдёт, но ответ будет заблокирован, если origin не разрешён

Content-Type application/json — не из простого списка, значит запрос не simple, и браузер обязан сначала спросить разрешения: OPTIONS с перечнем метода и заголовков. Сервер отвечает Allow-набором, и только затем уходит настоящий POST. Ошибка этапа preflight выглядит как провал сети, хотя серверу запрос так и не дошёл.

### Ошибки: ConnectError в TypeScript

Теперь ошибки — и здесь всё до неприличия знакомо. Пойманный в catch объект имеет тип ConnectError из @connectrpc/connect, и его поля — это проекция *connect.Error из Go-урока: code — строковое имя кода (not_found, unauthenticated, unavailable), rawMessage — сообщение, а metadata — заголовки-метаданные вызова. Код, который сервер вернул через connect.NewError(CodeNotFound, ...), фронтенд увидит как err.code === CodeNotFound — таблица HTTP-маппинга из [урока про ошибки](/courses/connect/lessons/06-oshibki/) остаётся вашим картой и в TS. Практика фронтенда добавляет один нюанс: браузерный клиент не отличает сетевые сбои от серверных отказов без кода — на упавший fetch или отказ preflight придёт ConnectError с кодом unknown, и UI должен решать, показывать ли пользователю retry. Для перехватчиков всё симметрично седьмому уроку: транспорт принимает interceptors — функции (next) => (req) => {...}, где req.header.set навешивает Authorization на каждый запрос, а next пропускает вызов дальше. Стек тот же: один auth-перехватчик на транспорте — и ни один вызов не забудет токен.

### Обработка ошибок и перехватчик на транспорте

```go
import { ConnectError, Code, Interceptor } from "@connectrpc/connect";

const auth: Interceptor = (next) => async (req) => {
  req.header.set("Authorization", "Bearer " + getToken());
  return next(req);
};

try {
  const res = await client.getNote({ id: "42" });
  render(res);
} catch (e) {
  if (e instanceof ConnectError && e.code === Code.NotFound) {
    showEmptyState(); // 404-ветка из урока про ошибки
  } else {
    throw e; // unknown на сетевом сбое — наверх, в retry-логику
  }
}
```

Тот же контракт, другой язык: Code.NotFound — статический enum, e.code — имя кода, e.rawMessage — текст, e.metadata — заголовки. Перехватчик Interceptor принимает next и возвращает обёртку вызова: заголовок ставится до next(req). Подключается опцией interceptors: [auth] в конструкторе транспорта. Сравните с клиентским authInterceptor из седьмого урока — идея один в один, сигнатуры адаптированы под промисы.

### Что напечатает консоль?

```go
// Сервер: GetNote вернул connect.NewError(CodeNotFound, ...)
// Фронтенд на чужом origin, CORS-заголовки настроены верно.
try {
  await client.getNote({ id: "no-such-id" });
} catch (e) {
  console.log(e instanceof ConnectError, e.code);
}
```

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

- false, TypeError: не ConnectError
- ✅ true, not_found
- true, unavailable: сеть есть, сервер отказал
- true, internal: сервер не смог обработать

Серверный CodeNotFound превращается в HTTP 404 и JSON-тело с кодом not_found, транспорт разворачивает его в ConnectError, instanceof срабатывает, e.code — строка not_found из enum. Это та же таблица маппинга, что в шестом уроке: код протокола не теряется ни в Go-клиенте, ни в браузере — контракт один на всех.

### Стримы из браузера: AsyncIterable вместо цикла Receive

Восьмой урок научил циклу Receive с io.EOF на Go; в TypeScript-мире та же идея выражена идиоматичнее — асинхронными итераторами. Server streaming вызов возвращает AsyncIterable, и чтение потока — это for await: for await (const res of client.introduce({})) { render(res) } — цикл сам завершится, когда сервер закроет поток, а ошибка в середине прилетит исключением из цикла. Никаких ручных проверок EOF: конструкция for await спрятала внутри ровно ту семантику, которую мы разбирали в Go, — сигналом конца служит нормальное завершение итератора, а io.EOF превратился в отсутствие следующего элемента. Bidi-вызов возвращает объект с двумя полями: requests — источник, куда вы пишете свои сообщения, и responses — итератор ответов сервера. Полудуплексы из восьмого урока на месте: requests.send шлёт реплику, requests.complete закрывает вашу половину — аналог CloseRequest. Под капотом остаётся тот же фрейминг, что и в Go: каждое сообщение — отдельный фрейм в теле ответа. Стриминговые вызовы в браузере требуют Connect- или gRPC-Web-протокола — и напомним: bidi по-прежнему только на HTTP/2, ограничение протокола не отменялось.

### for await по серверному потоку

```go
// Introduce: server streaming, сервер шлёт фразы по одной.
for await (const res of client.introduce({})) {
  appendPhrase(res.sentence); // каждое сообщение — отдельный рендер
}
// Цикл завершится сам, когда сервер закроет поток.

// Converse: bidi — два полудуплекса.
const stream = client.converse();
await stream.requests.send({ sentence: "Привет" });
await stream.requests.complete(); // аналог CloseRequest
for await (const res of stream.responses) {
  appendPhrase(res.sentence);
}
```

Идиома AsyncIterable — это Receive-цикл из восьмого урока, переписанный языком TypeScript: итератор завершается там, где Go-код видел io.EOF; ошибка в середине потока выбросит ConnectError из цикла. Для bidi объект-поток держит обе половины: requests — отправку, responses — приём. Управление завершением и ошибки те же, что в Go: Cancelled при уходе со страницы, deadline по timeoutMs опции вызова.

**Вопрос:** Чем createGrpcWebTransport отличается от createConnectTransport?

- Он быстрее: использует WebSocket вместо fetch
- ✅ Тем же API клиента, но протоколом gRPC-Web на проводе: фрейминг и статусы в заголовках
- Он не поддерживает unary-вызовы
- Ничем: это переименованный экспорт того же конструктора

Клиент, типы и код ошибок идентичны — меняется только протокол на проводе: gRPC-Web с пятибайтовыми конвертами и Grpc-Status в заголовках вместо собственного протокола Connect с HTTP-статусами. gRPC-Web выбирают, когда бэкенд — классический gRPC за Envoy, как в gRPC-курсе про проксирование; для Connect-сервера проще транспорт по умолчанию.

### Три способа вызвать бэкенд из браузера

| Вариант | Транспорт | HTTP/1.1 | Двунаправленный стриминг |
| --- | --- | --- | --- |
| Connect-протокол | createConnectTransport (fetch) | да, unary и server streaming | только на HTTP/2 |
| gRPC-Web | createGrpcWebTransport (fetch) | да, с оговорками | нет: half-duplex |
| Классический gRPC | невозможен: нет доступа к трейлерам | нет: требует HTTP/2-фреймы | нет: нужен прокси |

### Соберите браузерный клиент Eliza

Восстановите транспорт, клиент и вызов: конструкторы connect web-стека и метод rpc.

```go
import { createClient } from "@connectrpc/connect";
import { create___Transport } from "@connectrpc/connect-web";
import { ElizaService } from "./gen/eliza/v1/eliza_pb";

const transport = create___Transport({
  baseUrl: "http://localhost:8080",
});

const client = createClient(___, transport);

const res = await client.___({ sentence: "I feel happy." });
```

Подсказка: Транспорт собственным протоколом — createConnectTransport; клиент строится из сервис-объекта ElizaService; имя метода — rpc Say в camelCase.

Правильные ответы в пропусках: `Connect`, `Connect`, `ElizaService`, `say`

### Путь первого запроса из браузера

Расставьте события первого вызова client.say с чужого origin по порядку.

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

1. `createConnectTransport построил транспорт на fetch с baseUrl`
2. `createClient(ElizaService, transport) вернул typed-клиент`
3. `Клиент готовит POST на /connectrpc.eliza.v1.ElizaService/Say`
4. `Браузер видит не-simple Content-Type и шлёт OPTIONS preflight`
5. `Сервер отвечает 204 с Access-Control-Allow-Origin и заголовками`
6. `Preflight пройден: браузер отправляет настоящий POST с JSON`
7. `Mux направляет вызов в eliza-сервер, диспетчер по Content-Type`
8. `Ответ 200 с JSON-телом: промис resolve, UI показывает реплику`

> **Один контракт — две колонки генерации.** Держите buf generate как единую точку сборки обоих языков: правка схемы плюс один прогон обновляет и Go-код сервера, и TypeScript-модули фронта. Как только в проекте появляется ручное копирование типов между языками — контракт перестаёт быть единым. CI-проверка из следующего урока (buf lint и buf breaking) охраняет схему в репозитории, а генерация из неё охраняет согласованность клиента и сервера.

> **Как проходить этот курс.** Код уроков — настоящий Go и TypeScript с генерацией из proto: браузерная песочница сайта сетевой код исполнить не может, поэтому runner-блоков в курсе нет — для практики нужен локальный проект из второго урока и npm в каталоге фронта. Интерактивы работают прямо на странице и дают XP, а клиентский код копируйте в свой фронтенд.

### Карточки: connect web

- **@connectrpc/connect-web** — Браузерный пакет стека connect web: createConnectTransport для собственного протокола и createGrpcWebTransport для gRPC-Web. Транспорт ходит через fetch.
- **createClient(ElizaService, transport)** — Строит typed-клиент из сервис-объекта сгенерированного модуля: метод на каждый rpc, plain-объекты запросов, Promise или AsyncIterable в ответ.
- **preflight (OPTIONS)** — Первый запрос с чужого origin при не-simple Content-Type: браузер спрашивает разрешение. Сервер отвечает Access-Control-Allow-Origin/Methods/Headers, иначе настоящий запрос не уйдёт.
- **ConnectError** — TS-проекция *connect.Error: code (например Code.NotFound или строка not_found), rawMessage, metadata. Ловится обычным try/catch вокруг await вызова.
- **for await по стриму** — Чтение server streaming в TS: client.introduce({...}) возвращает AsyncIterable; конец серверного потока завершает цикл, ошибка в середине — исключение из него.
- **Interceptors в TS** — Функции (next) => (req) => {...} на транспорте: req.header.set перед next(req). Тот же стек, что WrapUnary в Go — auth, трассировка, метрики.

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

Круг из первого урока замкнут: фронтенд на TypeScript говорит с Go-сервером напрямую — тот же контракт, те же коды, тот же фрейминг. Фронтенд-стек связан с сервером одним артефактом — схемой, и именно схеме теперь нужна охрана: в [десятом уроке](/courses/connect/lessons/10-buf-lint/) берём в руки buf lint и buf breaking — линт протофайлов, категории правил, конфиг buf.yaml и проверку breaking changes в CI, чтобы эволюция контракта не ломала ни Go-сервер, ни TS-клиент. Для контекста полезно вспомнить, как та же задача решалась в gRPC-мире с проксированием — об этом был [урок про Envoy и gRPC-Web](/courses/grpc-go/lessons/24-proksirovanie/) в курсе gRPC: Connect снимает половину этой инфраструктуры. Программа всех двадцати пяти уроков — на [странице курса](/courses/connect/).

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

- Браузерный стек connect web: @bufbuild/protobuf (рантайм), @bufbuild/protoc-gen-es (генерация TS из .proto), @connectrpc/connect-web (транспорты).
- createConnectTransport ходит собственным протоколом Connect через fetch; createGrpcWebTransport — тем же клиентским API, но по gRPC-Web.
- createClient(ElizaService, transport) возвращает typed-клиент: методы-промисы для unary, AsyncIterable для server streaming.
- CORS: application/json не simple — браузер шлёт OPTIONS preflight; сервер обязан отвечать Allow-Origin/Methods/Headers, иначе запрос не уйдёт.
- Ошибки — ConnectError с code, rawMessage и metadata: та же таблица кодов, что в Go; unknown приходит на сетевые сбои без серверного кода.
- Перехватчики — функции (next) => (req) => {...} на транспорте: auth-заголовок ставится в одном месте, как WrapUnary в Go.
- Bidi в браузере — stream.requests и stream.responses; двунаправленный стриминг по-прежнему требует HTTP/2.

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

Соберите минимальный фронтенд-клиент к своему eliza-серверу: добавьте в buf.gen.yaml плагин protoc-gen-es с выходом в src/gen, поставьте @connectrpc/connect-web и @bufbuild/protoc-gen-es, сгенерируйте модули и постройте клиент через createConnectTransport и createClient. Реализуйте три сценария и проверьте их из консоли браузера: unary say с фразой «I feel happy.», server streaming introduce через for await (счётчик фраз выведите в DOM или консоль) и обработку ошибки — вызовите getNote с несуществующим id к notes-сервису и убедитесь, что instanceof ConnectError и e.code дают not_found, как серверный CodeNotFound из шестого урока. Затем поднимите сервер на localhost:8080 и фронт на localhost:3000: воспроизведите отказ preflight (уберите Access-Control-Allow-Headers), зафиксируйте текст ошибки в консоли, верните заголовки на место и опишите в заметках, какие именно заголовки нужны вашему стеку. Финальный штрих: подключите auth-перехватчик на транспорте и убедитесь через вкладку Network, что каждый запрос уходит с Authorization.

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

### Нужен ли Envoy-прокси, чтобы вызывать Connect-сервер из браузера?

Нет: собственный протокол Connect спроектирован без зависимости от трейлеров и низкоуровневых фич HTTP, поэтому fetch-транспорт ходит напрямую. Прокси нужен только классическому gRPC (или gRPC-Web-совместимому бэкенду) — и в этом случае в браузере используется createGrpcWebTransport.

### Какие заголовки обязательно разрешить в CORS для connect-web?

Минимум Content-Type и клиентские Connect-Timeout-Ms, Grpc-Timeout, X-User-Agent в Access-Control-Allow-Headers, плюс origin фронта в Allow-Origin и методы POST, GET, OPTIONS. Для gRPC-Web добавляются X-Grpc-Web и Access-Control-Expose-Headers: Grpc-Status, Grpc-Message — иначе клиент не увидит код ошибки.

### Как в TypeScript читать серверный стрим?

Server streaming вызов возвращает AsyncIterable: for await (const res of client.introduce({...})) читает сообщения по одному, цикл завершается, когда сервер закрывает поток, а ошибка в середине прилетает ConnectError из цикла. Для bidi объект содержит stream.requests и stream.responses.
