Всем привет, на связи Дмитрий Дин! Все еще евангелист 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, я бы привел в пример древо Иггдрасиль из сериала «Локи».

Условная схема сущностей и взаимосвязей фреймворка Chord
Условная схема сущностей и взаимосвязей фреймворка 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>
  );
}  

Репозиторий на GitHub

Чтобы показать, как это работает на практике, предлагаю расширить пример. Добавим на сервере новую функцию 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)


  1. cannibal_corpse
    16.06.2026 11:26

    Chord-возьми! Неплохо :)


    1. zelenin
      16.06.2026 11:26

      только он Корд


      1. cannibal_corpse
        16.06.2026 11:26

        Судя по докладу с Холи прошлого сезона, автор придерживается все-таки «Чорд»;)


      1. zede
        16.06.2026 11:26

        Автор намеренно используется наименование Чорд.


  1. KwI
    16.06.2026 11:26

    Привет! Классное решение, вдохновляет, спасибо за статью!

    Вопрос про rebac: что в статье, ч о в репозитории примеры объявления связей сущностей статичные - такому пользователю такие права в проект. Как вы это переносите на динамику? Когда например владелец документа выдал прав на редактирование, и надо в рантайме в rpc-ручке проверить, что подьзователь , полученный из заголовков запроса, имеет такой-то доступ к документу, id которого передан в теле rpc-запроса?