Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 9 из 25 ~22 мин +50 XP

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

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

Содержание урока · 10 разделов

Вспомните, с чего начался курс: в первом уроке мы говорили, что классический 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: вторая колонка плагинов

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-статусы по таблице кодов — всё это вы уже разбирали в уроке про протоколы. Есть и второй конструктор — 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 не работает из браузера напрямую?

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-перехватчик на транспорте — и ни один вызов не забудет токен.

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

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 из седьмого урока — идея один в один, сигнатуры адаптированы под промисы.

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

Прочитайте код и выберите, что он выведет:

// Сервер: 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-WebcreateGrpcWebTransport (fetch)да, с оговоркаминет: half-duplex
Классический gRPCневозможен: нет доступа к трейлерамнет: требует HTTP/2-фреймынет: нужен прокси

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

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

import { createClient } from "@connectrpc/connect"; import { createTransport } from "@connectrpc/connect-web"; import { ElizaService } from "./gen/eliza/v1/eliza_pb"; const transport = createTransport({ baseUrl: "http://localhost:8080", }); const client = createClient(, transport); const res = await client.({ sentence: "I feel happy." });

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: connect web

Термин

@connectrpc/connect-web

нажмите, чтобы перевернуть
Определение

Браузерный пакет стека connect web: createConnectTransport для собственного протокола и createGrpcWebTransport для gRPC-Web. Транспорт ходит через fetch.

1 / 6

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

Круг из первого урока замкнут: фронтенд на 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Connect · Браузерные клиенты (TypeScript))

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

Нужен ли 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.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding