Содержание урока · 10 разделов
- Кодогенерация: protoc-gen-es и buf.gen.yaml
- Транспорт: createConnectTransport на fetch
- CORS: preflight и заголовки, которые надо разрешить
- Ошибки: ConnectError в TypeScript
- Стримы из браузера: AsyncIterable вместо цикла Receive
- Три способа вызвать бэкенд из браузера
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Вспомните, с чего начался курс: в первом уроке мы говорили, что классический gRPC в браузере не работает — нет доступа к HTTP/2-фреймам и трейлерам, а значит нужен Envoy-прокси или gRPC-Web. Connect возник как ответ именно на эту боль, и настал урок, где мы закрываем круг: напишем фронтенд-клиент на TypeScript, который ходит в наш eliza-сервис из третьего урока напрямую, без прокси. Стек, который в поиске часто описывают словами connectrpc браузер, состоит из трёх частей: @bufbuild/protobuf — рантайм protobuf-типов, @bufbuild/protoc-gen-es — плагин, генерирующий TypeScript из тех же .proto, и @connectrpc/connect-web — браузерные транспорты. Весь этот стек называется коротко connect web, а поисковое имя связки — connect typescript: генерация типов из proto плюс рантайм для браузера. Его главная философия вас уже не удивит: тот же контракт, тот же путь процедуры, те же коды ошибок — только язык другой. TypeScript-клиент говорит по-русски протокола Connect ровно так же, как Go-клиент из пятого урока: вызов 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: вторая колонка плагинов
GOversion: 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-статусы по таблице кодов — всё это вы уже разбирали в уроке про протоколы. Есть и второй конструктор — createGrpcWebTransport: тот же клиентский API, но на проводе протокол gRPC-Web с его фреймингом и статусами в трейлерах-заголовках; он нужен, когда бэкенд — классический gRPC-сервер, прикрытый Envoy, или сервер Connect, к которому идёт деплой с gRPC-Web-совместимостью. Выбор транспорта — единственное место, где фронтенд знает о проводе; сам клиент от него не зависит. Транспорт fetch-овой природы означает и все стандартные следствия: куки и заголовки браузер ставит сам, redirect'ы и кеширование работают как в обычных fetch-вызовах, а GET-запросы для процедур с idempotency_level = NO_SIDE_EFFECTS — тот самый GET-кеш из четвёртого урока — кешируются браузерной инфраструктурой по-настоящему.
Первый вызов из браузера
GOimport { 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 не работает из браузера напрямую?
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?
Ошибки: ConnectError в TypeScript#
Теперь ошибки — и здесь всё до неприличия знакомо. Пойманный в catch объект имеет тип ConnectError из @connectrpc/connect, и его поля — это проекция *connect.Error из Go-урока: code — строковое имя кода (not_found, unauthenticated, unavailable), rawMessage — сообщение, а metadata — заголовки-метаданные вызова. Код, который сервер вернул через connect.NewError(CodeNotFound, ...), фронтенд увидит как err.code === CodeNotFound — таблица HTTP-маппинга из урока про ошибки остаётся вашим картой и в TS. Практика фронтенда добавляет один нюанс: браузерный клиент не отличает сетевые сбои от серверных отказов без кода — на упавший fetch или отказ preflight придёт ConnectError с кодом unknown, и UI должен решать, показывать ли пользователю retry. Для перехватчиков всё симметрично седьмому уроку: транспорт принимает interceptors — функции (next) => (req) => {...}, где req.header.set навешивает Authorization на каждый запрос, а next пропускает вызов дальше. Стек тот же: один auth-перехватчик на транспорте — и ни один вызов не забудет токен.
Обработка ошибок и перехватчик на транспорте
GOimport { 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 из седьмого урока — идея один в один, сигнатуры адаптированы под промисы.
Что напечатает консоль?
Прочитайте код и выберите, что он выведет:
// Сервер: GetNote вернул connect.NewError(CodeNotFound, ...)
// Фронтенд на чужом origin, CORS-заголовки настроены верно.
try {
await client.getNote({ id: "no-such-id" });
} catch (e) {
console.log(e instanceof ConnectError, e.code);
}Стримы из браузера: 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?
Три способа вызвать бэкенд из браузера#
| Вариант | Транспорт | HTTP/1.1 | Двунаправленный стриминг |
|---|---|---|---|
| Connect-протокол | createConnectTransport (fetch) | да, unary и server streaming | только на HTTP/2 |
| gRPC-Web | createGrpcWebTransport (fetch) | да, с оговорками | нет: half-duplex |
| Классический gRPC | невозможен: нет доступа к трейлерам | нет: требует HTTP/2-фреймы | нет: нужен прокси |
Соберите браузерный клиент Eliza
Восстановите транспорт, клиент и вызов: конструкторы connect web-стека и метод rpc.
Путь первого запроса из браузера
Расставьте события первого вызова client.say с чужого origin по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Держите buf generate как единую точку сборки обоих языков: правка схемы плюс один прогон обновляет и Go-код сервера, и TypeScript-модули фронта. Как только в проекте появляется ручное копирование типов между языками — контракт перестаёт быть единым. CI-проверка из следующего урока (buf lint и buf breaking) охраняет схему в репозитории, а генерация из неё охраняет согласованность клиента и сервера.
Код уроков — настоящий Go и TypeScript с генерацией из proto: браузерная песочница сайта сетевой код исполнить не может, поэтому runner-блоков в курсе нет — для практики нужен локальный проект из второго урока и npm в каталоге фронта. Интерактивы работают прямо на странице и дают XP, а клиентский код копируйте в свой фронтенд.
Карточки: connect web
Что дальше по курсу#
Круг из первого урока замкнут: фронтенд на TypeScript говорит с Go-сервером напрямую — тот же контракт, те же коды, тот же фрейминг. Фронтенд-стек связан с сервером одним артефактом — схемой, и именно схеме теперь нужна охрана: в десятом уроке берём в руки buf lint и buf breaking — линт протофайлов, категории правил, конфиг buf.yaml и проверку breaking changes в CI, чтобы эволюция контракта не ломала ни Go-сервер, ни TS-клиент. Для контекста полезно вспомнить, как та же задача решалась в gRPC-мире с проксированием — об этом был урок про Envoy и gRPC-Web в курсе gRPC: Connect снимает половину этой инфраструктуры. Программа всех двадцати пяти уроков — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Браузерный стек 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding