
Всем привет, на связи Дмитрий Дин! Все еще евангелист 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.
А вы не устали от ручного описания запросов? Делитесь в комментариях, как оптимизируете свою разработку.























