
Всем привет, на связи Дмитрий Дин! Все еще евангелист Svelte и тимлид в Далее на проекте крупной маркетингово-аналитической платформы. Кроме того, у нас есть внутренний рыночный продукт — инструмент для дата-инженерии SubQuery.
Оба проекта изначально были написаны на стеке SvelteKit и TypeScript со стандартным REST-подходом. Сейчас практически все переписано на Chord — что сократило 15% из 150К строк кода и ускорило поставку новых фич.
Chord — мой собственный фреймворк поверх JSON-RPC, про который я вскользь упоминал в статье о графовой реактивности в BI-системе. Сегодня же поведаю, как с его помощью сделать сетевое взаимодействие декларативным.
Chord — наше фундаментальное решение проблем взаимодействия фронта с бэком
Современная веб-разработка — сложная. Растет пул технологий, всем нужны отзывчивые интерфейсы, сравнимые с нативными приложениями, а ответственность размазывается по областям разработки.
При этом фронтенд и бэкенд зачастую решают одну и ту же бизнес-задачу. Работают с одинаковыми сущностями и структурами. Обрабатывают одни и те же действия и события. Иногда создается ощущение, что бэкенд просто реализует интерфейс для работы с базой данных.
И дальше начинается сетевой слой.
Нужны ручки. Отдельные endpoint’ы. Обновление данных. Сложные сценарии. Одними экшенами и формами не обойтись. SSR — это только односторонний поток данных. Бизнесу нужна гибкость и отзывчивость.
В итоге значительная часть кода начинает уходить не на бизнес-логику, а на обслуживание сетевого взаимодействия.
Сейчас приведу самый распространенный пример кода, который используют в проектах и знаком почти всем. Но я в нем вижу очень много потенциальных проблем или того, что может к ним привести:
// Дубликация типов (а тут еще нет бэка) interface User { email: string; name: string; } interface DBUser extends User { id: number } const createUser = async (data: User): Promise<DBUser> => // А если промахнулись с типом? fetch('/api/users/new', // Очепятка и 404? { headers: { "Content-Type": "application/json", // Хедер не забыли? }, method: "POST", // Точно POST, а вдруг PUT? body: JSON.stringify(data), }).then((res) => res.json() // Надоевший бойлерплейт );
И таких ручек нужно от 10 до 20 на модуль ? Все это еще пилить на бэке ???
Вот здесь и становится заметно, сколько сетевого шума появляется вокруг простой операции создания пользователя. Я устал от этого бойлерплейта и пошел искать альтернативы.
Мы начинали с REST API, ведь это самый популярный подход. Звучит логично: есть ресурсы, есть CRUD, есть OpenAPI и Swagger. Все структурировано. Но ручки — это не всегда просто «ресурс». Бизнес-задачи сложнее. Появляется избыточность. Типы и структуры все равно дублируются. Плюс — каждый пишет REST по-своему.
Можно было посмотреть в сторону GraphQL. Он не избыточен и строго типизирован. Вся схема данных в одном месте. Developer Experience вроде бы должен быть лучше. Но дальше начинаются мутации, безопасность, авторизация. Клиент становится жирным, а обработка запросов — отдельная большая история.
Есть gRPC. Он обладает строгой типизацией, автогенерацией клиентов и минимальным трафиком. Но у gRPC бинарный формат. Для фронтенда это уже не так удобно: сложнее дебажить и смотреть, что реально улетает в сеть. Чаще gRPC используют между микросервисами, а не на границе клиент-сервер. В Node.js и браузере работа с JSON наиболее эффективна.
Нам хотелось оставить простую модель — вызов функций через RPC, без сетевого описания, но с сохранением прозрачности.
И тут на сцену выходит JSON-RPC. Это простейший transport-agnostic протокол со спецификацией на одну страницу. В HTTP-варианте использует только POST-запросы. Для документирования есть OpenRPC — аналог OpenAPI.
// in { "jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1 } // out { "jsonrpc": "2.0", "result": 19, "id": 1 }
Нам осталось лишь добавить строгую типизацию на TypeScript и генерацию клиента.
Так появился Chord.
Как устроен Chord
Chord — это фреймворк сетевого уровня, который позволяет бесшовно соединять бэкенды и фронтенды внутри мета-фреймворков. У него есть своя документация, сделанная на Astro.
Если бы меня попросили как-то визуализировать Chord, я бы привел в пример древо Иггдрасиль из сериала «Локи».

На схеме видна бизнес-логика, которую создает программист. Она отмечена сиреневым.
Слева у нас — бэкенд: User Service, Cart Service, Order Service. Внутри этих сервисов — методы: create, get, update, push, receive. Все это обычные классы и функции.
Справа — фронтенд. Здесь уже идут вызовы методов rpc.User.get(data), rpc.Cart.add(product), rpc.Order.push(cart). Они привязаны к UI, событиям, реактивности.
Посередине расположены элементы, которые предоставляет Chord. На стороне бэкенда это Composer. Он объединяет сервисы в единую сущность и обслуживает POST-endpoint. Одновременно с ним формируется TypeScript-тип, который можно экспортировать на фронтенд и использовать в качестве generic. Так типы «перетекают» с бэка на фронт.
POST-endpoint отмечен на схеме розовым. Он может отличаться в зависимости от выбранного мета-фреймворка — SvelteKit, Next, Nuxt, Nest, Express или любого другого. Нужен лишь адаптер под конкретную среду.
В итоге бизнес-логика остается изолирована от сетевого слоя и при этом легко связывается.
Теперь посмотрим, как это выглядит в коде — бэкенд / +server.ts:
import { json } from '@sveltejs/kit'; import { Composer, rpc, type Composed } from '@chord-ts/rpc'; // Main components of Chord we will use import { sveltekitMiddleware } from '@chord-ts/rpc/middlewares'; // Middleware to process RequestEvent object // 1. Implement the class containing RPC methods class Say { @rpc() // Use decorator to register callable method hello(name: string): string { return `Hello, ${name}!`; } } // 2. Init Composer instance that will handle requests export const _composer = Composer.init({ Say: new Say() }); // 3. Create a type that will be used on frontend export type Client = typeof _composer.clientType; _composer.use(sveltekitMiddleware()); // Use middleware to process SvelteKit RequestEvent // 4. SvelteKit syntax to define POST endpoint export async function POST(event) { // Execute request in place and return result of execution return json(await _composer.exec(event)); }
Здесь у нас серверная часть на SvelteKit.
Сначала мы объявляем класс и помечаем нужный метод декоратором @rpc(). Этим мы регистрируем его как RPC-функцию — то есть делаем вызываемым извне. Дальше подключаем этот класс к Composer. Composer инициализируется с набором сервисов — в данном случае это Say. После этого добавляем middleware под SvelteKit и используем Composer внутри POST-обработчика.
В функции POST мы просто передаем запрос в _composer.exec(event). Он сам определяет, какой метод нужно вызвать, выполняет его и возвращает результат. И в конце мы экспортируем тип клиента и можем импортировать его уже на фронтенде.
Chord в виде кода — фронтенд / +page.svelte:
<script lang="ts"> import { client } from '@chord-ts/rpc/client'; import { onMount } from 'svelte'; // Import our Contract import type { Client } from './+server'; // Init dynamic client with type checking // Use Contract as Generic to get type safety and hints from IDE // dynamicClient means that RPC will be created during code execution // and executed when the function call statement is found const rpc = client<Client>({ endpoint: '/' }); let res: string; // Called after Page mount. The same as useEffect(..., []) onMount(async () => { // Call method defined on backend with type-hinting res = await rpc.Say.hello('world'); console.log(res); }); </script> <h1>Chord call Test</h1> <p>Result: {res}</p>
Перед вами часть фронтенда на Svelte 4. Здесь мы используем фабрику RPC-клиента и передаем в нее сгенерированный тип с бэкенда.
onMount — хук, который вызывается при монтировании компонента. В нем мы производим вызов и записываем результат. По сути, это аналог useEffect(..., []).
Самое интересное, что по коду все выглядит так, будто функция находится на фронте. Но в реальности она исполняется на сервере. При этом IDE подсказывает нам аргументы и возвращаемый тип — все строго типизировано. Мы просто вызываем метод и получаем результат в UI.
И это не ограничивается Svelte. Тот же подход работает, например, в Nuxt и Next.js — синтаксис немного отличается, но концепция остается прежней.
Бэкенд Nuxt:
import { eventHandler, readBody } from 'h3'; import { Composer, toRPC } from '@chord-ts/rpc' class Say { hello(name: string): string { return `Hello, ${name}!`; } } const composer = Composer.init({ Say: toRPC(new Say()), }); export type Client = typeof composer.clientType; export default eventHandler(async (event) => { const body = await readBody(event); const result = await composer.exec(body); return result; });
Фронтенд на Vue:
<script setup lang="ts"> import { ref, onMounted } from 'vue'; import { client } from '@chord-ts/rpc/client'; import type { Client } from '../server/api/rpc'; const rpc = client<Client>({ endpoint: '/api/rpc' }); const res = ref<string | null>(null); onMounted(async () => { res.value = await rpc.Say.hello('world'); console.log(res.value); }); </script> <template> <div> <h1 class="text-sm text-primary">Test Endpoint</h1> {{ res }} </div> </template>
Бэкенд на Next:
import { type NextRequest } from 'next/server'; import { Composer, toRPC } from '@chord-ts/rpc'; class Say { hello(name: string): string { return `Hello, ${name}!`; } } const composer = Composer.init({ Say: toRPC(new Say()), }); export type Client = typeof composer.clientType; export async function POST(request: NextRequest) { const body = await request.json(); const result = await composer.exec(body); return Response.json(result); }
Фронтенд на React:
'use client'; import { useEffect, useState } from 'react'; import { client } from '@chord-ts/rpc/client'; import type { Client } from './rpc/route'; const rpc = client<Client>({ endpoint: '/rpc' }); export default function HelloPage() { const [res, setRes] = useState<string | null>(null); useEffect(() => { const fetchData = async () => { const result = await rpc.Say.hello('world'); setRes(result); console.log(result); }; fetchData(); }, []); return ( <div> <h1 className="text-sm text-blue-600">Test Endpoint</h1> <p>{res}</p> </div> ); }
Чтобы показать, как это работает на практике, предлагаю расширить пример. Добавим на сервере новую функцию sum(a: number, b: number) и сразу вызовем ее на фронтенде:
В видео видно:
как после добавления метода он появляется в IDE на фронте,
как редактор показывает разработчику сигнатуру функции и ожидаемые аргументы,
как результат выполнения приходит в UI.
Это наглядная демонстрация того, как типы «перетекают» с бэкенда на фронтенд и начинают работать как единый контракт.
Пример вызова rpc.Service.method()
Вы уже увидели, как все выглядит снаружи. В этот момент формируется RPC-запрос. Возникает логичный вопрос: что происходит между обращением к rpc.User.get(...) и выполнением метода на сервере?
Здесь используется Proxy.
На всякий случай напомню, что в JavaScript есть объект Proxy. Он позволяет создать обертку над исходным объектом и перехватывать обращения к его полям и вызовы методов. Разработчик работает с объектом как обычно, но мы можем изменить поведение get, apply и других операций.

Благодаря этому инструменту мы буквально можем управлять «реальностью» объекта. Кстати, Proxy активно используется во фронтенд-фреймворках для реализации реактивности — например, в Vue или Svelte. У нас он лежит в основе всего механизма.

Слева на схеме показан пример реализации через класс. Это небольшой лайфхак: можно обернуть Proxy в класс и «закольцевать» его на самого себя. Внутри этого класса дальше реализуются методы, которые управляют прокси.
Справа находится шаблон запроса. Мы будем постепенно наполнять его по мере обращения к полям.
Сначала инициализируется пустой шаблон, и возвращается Proxy.

Дальше срабатывает get — это происходит, когда мы обращаемся к полю, например rpc.User. Внутрь попадает ключ User, мы регистрируем его как service и записываем в шаблон. После этого снова возвращаем тот же объект с прокси, но уже с обновленным состоянием.

Затем аналогично перехватывается обращение к методу — например, .get. Мы регистрируем его как method и снова возвращаем прокси.

Когда используются круглые скобки — rpc.User.get(data) — срабатывает apply. Внутрь попадают аргументы вызова. Мы считываем их и добавляем в шаблон запроса.
В результате шаг за шагом формируется структура JSON-RPC-запроса. В дефолтной реализации — стандартная JSON-сериализация, поэтому аргументы должны быть без сложных кастомных типов.
В 2023 году идея создания Chord сводилась к декларативному вызову серверных методов без ручного кода. Но по мере использования в реальных сервисах фреймворк получил новый функционал для сетевого взаимодействия. Мы добавили валидацию входных аргументов при помощи Zod, transport-agnostic слой, middleware и продвинутую авторизацию.

Валидаторы — простой синтаксис на базе Zod
В продакшне аргументы редко бывают идеальными. Поэтому понадобился способ валидировать их прямо на уровне RPC-метода — до выполнения бизнес-логики. Для этого добавили интеграцию с Zod.
Пример того, как это выглядит в коде:
const string = z.string() const number = z.number() const fio = z.object({ name: z.string(), secondName: z.string() }) type FIO = z.infer<typeof fio> class Service { @rpc() async hello(@val(string) name: string, @val(number) n: number) { const msg = `Hello ${name} ${new Date()} ${n}` return msg } @rpc() async multipleArgs(@val(fio) fio: FIO) { const msg = `Hello ${fio.name} ${fio.secondName} ${new Date()}` return msg } }
Сначала объявляем стандартную Zod-схему. Это может быть простой объект или более сложная структура с вложенными полями — все зависит от задачи. Из схемы выводим TypeScript-тип. В итоге у нас есть и правило валидации, и тип для компилятора. Дальше эту схему и тип можно применять в RPC-функциях.
Здесь есть интересный момент: декораторы можно вешать не только на методы, но и на аргументы функции. В данном случае декоратор принимает Z-схему и привязывается к конкретному параметру. Так мы явно обозначаем, какой валидатор относится к какому аргументу, и проверка выполняется до запуска бизнес-логики.
Агностицизм формата и транспорта — гибкость, которая действительно решает

Chord позволяет подменять протоколы, эндпойнты и форматы передачи данных. И в продакшне это реально спасало. Приведу пример из жизни.
У нас было взаимодействие с Яндекс.Метрикой. В какой-то момент у них произошло переполнение ID. Они сменили значение Integer на BigInt. В результате все перестало корректно работать — стандартная JSON-сериализация такие значения не поддерживает.
Нам помогла возможность подменить реализацию транспорта. Мы подключили библиотеку devalue от Рича Харриса, которая умеет работать со сложными типами, включая BigInt. И с помощью одной функции сериализации мы заставили систему снова заработать.

Вышел аккуратный фикс без переписывания бизнес-логики. И это не единственный случай. Были сценарии, где требовалась передача FormData, работа с URLSearchParams, использование MessagePack для более компактной передачи данных при больших JSON.
Поскольку формат и транспорт можно подменить, система не ограничивается только стандартным JSON. Можно выбрать подходящий вариант под конкретную задачу.
Авторизация — лаконичные проверки RBAC, ABAC и ReBAC
У нас есть разные модели авторизации. Проведем краткий экскурс по ним.
Самая простая — это RBAC (Role-Based Access Control):

У пользователя есть роль, в зависимости от которой ему разрешается или запрещается доступ к объекту. Например, один пользователь может работать с документом, другой — нет.
Модель посложнее — ABAC (Attribute-Based Access Control):

Здесь учитываются атрибуты и пользователя, и самого объекта. Например, документ может быть публичным, тогда к нему имеют доступ все анонимные пользователи. Или приватным — с доступом только определенной команды.
Есть и более сложная модель — ReBAC (Relation-Based Access Control):

В этом случае сущности связываются в граф с отношениями. Отношение можно интерпретировать как роль. Пользователь может быть создателем документа, его автором, участником проекта и так далее. Здесь проверка доступа — это обход графа.

Это схема из внутреннего продукта SubQuery. У нас много сущностей и вложенных связей: команда, проект, DBT-проект и другие объекты. Благодаря ReBAC можно одним запросом пройти весь этот граф и определить, имеет ли пользователь право работать с конкретным инструментом или документом.
Для реализации модели мы использовали Permify — сервис, который позволяет описывать и проверять подобные отношения.
Теперь посмотрим, как это выглядит в коде:
class Team { ... @rpc() async update(team: SelectTeam) { const memberId = this.ctx.member.id await check( this.ctx, () => checkLicense(this.ctx.member.teamId), () => throwable( can.member(memberId).write.team(team.id), 'Отсутствуют права на редактирование команды' ) ) ... } @rpc({ use: [authRpc('team', 'write', 'Нет прав на получение ролей')] }) async getRoles(teamId: string, memberId: string) { return getMemberRoles(teamId, memberId, 'team') } ... }
В коде есть возможность выполнить комплексную проверку по роли, атрибутам и связям.
Мы можем определить, является ли пользователь глобальным администратором сервиса или обычным кастомером. Потом — узнать о наличии лицензии. Наш сервис платный, поэтому нужно понять, оплатил ли пользователь доступ ? И после — посмотреть доступ к объекту. Например, может ли участник команды работать с этим проектом.
Все эти проверки можно сократить и вынести в middleware для декоратора, чтобы сделать шорткат-проверку и не дублировать код.
@chord-ts/rebac — это клиент для Permify, который позволяет выполнять гибкие проверки через синтаксис, близкий к английскому языку.
В коде мы описываем связанные сущности — пользователь, проект, роли admin, manager — и объединяем их в граф. После этого можно задавать вопросы системе об отношениях этих сущностей:
// Объявляем связи сущностей const tuple1 = rebac.tuple.user('1').admin.project('1') const tuple2 = rebac.tuple.user('2').manager.project('1') await rebac.connect(tuple1, tuple2) // Получаем список доступных действий над сущностью const permissions = await rebac.what.user('1').canDo.project('1') // Получаем список пользователей, которые могут выполнить действие const users = await rebac.who.project('1').delete.user() // Получаем список сущностей, доступных для действия const projects = await rebac.where.user('1').edit.project() // Узнаем, может ли пользователь совершить действие const hasAccess = await rebac.can.user('2').delete.project('1')
Разберем, как это устроено, на примере rebac.can.user(1).read.document(1)
Механизм устроен так же, как и в RPC — через Proxy.
Вначале мы обращаемся к классу rebac, и он возвращает нам прокси. Далее идет слово entry point — например can, where, what. Эти слова обозначают разные типы проверок.
Указываем
entry point can— фиксируем тип проверки.Возвращаем прокси, чтобы получить интересующую сущность.
Вызываем
user('1')— возвращаем callable-объект и принимаем ID пользователя. Сохраняем его в шаблон.Указываем действие
read— записываем его в шаблон запроса.Вызываем
document('1')— принимаем ID объекта и фиксируем его.
В финале выполняем обращение в Permify с уже собранной структурой: кто, какое действие и над каким объектом. По сути, мы поэтапно собираем декларативное выражение через Proxy, только теперь это не вызов метода, а проверка отношений.
Польза Chord на нашем проекте: меньше кода — проще поддержка
На проекте нашего клиента мы провели мягкую миграцию платформы с REST API на Chord. Переписали существующие endpoints и довольно быстро почувствовали разницу.
Мы смогли сократить time-to-market для доработок. Процесс стал проще, потому что исчез значительный объем кода, который обслуживал сеть: fetch, endpoints, ручная маршрутизация.
Плюсом мы получили строгую типизацию между сервером и клиентом, самодокументирующийся API и подсказки от IDE.
А вы не устали от ручного описания запросов? Делитесь в комментариях, как оптимизируете свою разработку.
Комментарии (5)

KwI
16.06.2026 11:26Привет! Классное решение, вдохновляет, спасибо за статью!
Вопрос про rebac: что в статье, ч о в репозитории примеры объявления связей сущностей статичные - такому пользователю такие права в проект. Как вы это переносите на динамику? Когда например владелец документа выдал прав на редактирование, и надо в рантайме в rpc-ручке проверить, что подьзователь , полученный из заголовков запроса, имеет такой-то доступ к документу, id которого передан в теле rpc-запроса?
cannibal_corpse
Chord-возьми! Неплохо :)
zelenin
только он Корд
cannibal_corpse
Судя по докладу с Холи прошлого сезона, автор придерживается все-таки «Чорд»;)
zede
Автор намеренно используется наименование Чорд.