<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrej Kirejeŭ</title>
    <description>The latest articles on DEV Community by Andrej Kirejeŭ (@andreik).</description>
    <link>https://dev.to/andreik</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F276658%2F60ed9ead-7595-459e-876a-6ecbcf91bad2.jpg</url>
      <title>DEV Community: Andrej Kirejeŭ</title>
      <link>https://dev.to/andreik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andreik"/>
    <language>en</language>
    <item>
      <title>Бухгалтерия на естественном языке: каким должен быть MCP-сервер. Часть 4</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:46:15 +0000</pubDate>
      <link>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-4-25i5</link>
      <guid>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-4-25i5</guid>
      <description>&lt;p&gt;В &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-3-1gf3"&gt;третьей части&lt;/a&gt; мы описали сервер: три уровня инструментов, от учётных регистров и отчётов до запросов к документам и SQL, и базу знаний с карточками правил проведения и определениями показателей. На простой вопрос такой сервер отвечает одним-двумя вызовами. Теперь возьмём вопрос, для ответа на который нужна цепочка рассуждений, а затем расскажем, что пошло не так при разработке.&lt;/p&gt;

&lt;h2&gt;
  
  
  Сложные вопросы
&lt;/h2&gt;

&lt;p&gt;Возьмём вопрос "почему упала прибыль в третьем квартале". Цепочку рассуждений строит модель на стороне клиента. Задача сервера — давать проверяемые факты: каждый инструмент возвращает цифру с указанием, как она получена, а выводы модель делает сама.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    actor U as Пользователь
    participant L as LLM
    participant S as MCP-сервер
    participant W as Веб-поиск
    U-&amp;gt;&amp;gt;L: Почему упала прибыль в III квартале?
    L-&amp;gt;&amp;gt;U: Сравниваем со II кварталом или с III кварталом прошлого года?
    U-&amp;gt;&amp;gt;L: С прошлым годом
    L-&amp;gt;&amp;gt;S: get_pnl([2025-Q3, 2026-Q3])
    S--&amp;gt;&amp;gt;L: прибыль -18%, валовая прибыль -12%
    L-&amp;gt;&amp;gt;S: compare_periods("Валовая прибыль", 2025-Q3, 2026-Q3, by="Номенклатура")
    S--&amp;gt;&amp;gt;L: три группы товаров дают 80% снижения
    L-&amp;gt;&amp;gt;S: factor_analysis(2025-Q3, 2026-Q3, by="Номенклатура")
    S--&amp;gt;&amp;gt;L: цена +2%, объём -4%, себестоимость единицы +11%
    L-&amp;gt;&amp;gt;S: get_one_off_items(2026-Q3)
    S--&amp;gt;&amp;gt;L: курсовые разницы по импортным поставкам
    L-&amp;gt;&amp;gt;W: курс валюты, цены поставщиков отрасли
    W--&amp;gt;&amp;gt;L: валюта подорожала на 9% за год
    L-&amp;gt;&amp;gt;U: Выводы, гипотезы, рекомендации&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Рис. 7 Цепочка вызовов при анализе причин снижения прибыли.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Методику анализа лучше не оставлять на вдохновение модели, а записать как сценарий: в одних клиентах это MCP prompts, в других — файл инструкций или навык. Для нашего вопроса сценарий примерно такой:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Уточнить период, базу сравнения и организацию.&lt;/li&gt;
&lt;li&gt;Получить отчёт о прибылях и убытках за оба периода и помесячную динамику.&lt;/li&gt;
&lt;li&gt;Найти статьи с наибольшим вкладом в отклонение.&lt;/li&gt;
&lt;li&gt;Разложить отклонение на факторы цены, объёма и структуры.&lt;/li&gt;
&lt;li&gt;Отделить разовые события от устойчивых тенденций.&lt;/li&gt;
&lt;li&gt;Сопоставить с внешней средой: курсы валют, спрос в отрасли, цены поставщиков, ставки.&lt;/li&gt;
&lt;li&gt;Сформулировать выводы и рекомендации.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ответ тоже стоит разметить. Сначала два-три предложения вывода с ключевыми цифрами. Дальше факты из учёта — с указанием, каким инструментом, за какой период и по какому показателю они получены. Потом внешние факторы со ссылками на источники. Гипотезы отдельно, вместе с тем, чем их можно проверить. И только в конце рекомендации, по возможности с оценкой эффекта. Так руководитель сразу видит, где цифры, а где догадки.&lt;/p&gt;

&lt;p&gt;И ещё одно правило, которое модель без явной инструкции соблюдает не всегда: в запросы к поисковой системе не должны попадать наименования контрагентов, суммы и прочие внутренние данные.&lt;/p&gt;

&lt;h2&gt;
  
  
  Чему нас научила разработка
&lt;/h2&gt;

&lt;p&gt;Архитектура из третьей части сложилась не сразу. Начинали мы с наивной схемы из первой части (рис. 1), и большинство проблем, описанных во второй, увидели именно на ней.&lt;/p&gt;

&lt;p&gt;Первый урок преподал главный бухгалтер. Он спросил, сколько мы должны одному из поставщиков. Модель ответила, что задолженности нет, хотя она была. Оказалось, что модель отбирала проводки по счёту 60 по объекту аналитики не того вида: искала поставщика среди договоров, а не среди контрагентов. В этом случае отбор не нашёл записей, и пустую сумму модель доложила как ноль. Главбух знал ответ заранее и проверял систему, поэтому ошибку заметили сразу. С тех пор сервер, получив пустой результат по счёту с аналитикой, сам добавляет к нему предупреждение: проверь, те ли объекты аналитики ты ищешь. По нашим наблюдениям, такая подсказка в ответе помогает заметно чаще, чем то же правило в общей документации.&lt;/p&gt;

&lt;p&gt;Второй урок — про несуществующие поля. Модель раз за разом просила у таблицы оборотов в корреспонденции поле, которое бывает только у таблицы остатков. СУБД возвращала ошибку обращения к несуществующему полю, модель пробовала снова, с другим, тоже несуществующим полем, и после трёх-четырёх попыток сдавалась. Помогла предварительная проверка: сервер ещё до выполнения смотрит, к каким полям обращается запрос, и сразу возвращает список допустимых с ближайшими по написанию вариантами. Теперь модель обычно исправляется с первой попытки.&lt;/p&gt;

&lt;p&gt;Ошибались, впрочем, не только модели. В одном из наших собственных инструментов отбор по счёту стоял во внешнем &lt;code&gt;WHERE&lt;/code&gt;, а не в параметрах функции, которая считает остатки. Упрощённо это выглядит так:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- было&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;account_saldo&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_code&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'62.%'&lt;/span&gt;
&lt;span class="c1"&gt;-- стало&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;account_saldo&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'62'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;include_subaccounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Без параметра счёта функция не знает, по каким объектам аналитики разворачивать сальдо, и возвращает по каждому счёту одну строку со свёрнутым остатком. Внешний &lt;code&gt;WHERE&lt;/code&gt; отбирает нужные строки, но детализация по покупателям уже потеряна внутри функции, и развёрнутое сальдо из этих строк не получить. Для свёрнутого остатка такой запрос всего лишь медленнее: функция считает остатки по всем счетам, а нужен один. Ошибку нашли, когда сверили ответ сервера с оборотно-сальдовой ведомостью по счёту 62.&lt;/p&gt;

&lt;p&gt;Запомнился и случай с зарплатой. Модель раз за разом собирала начисления запросом по проводкам, хотя в системе есть расчётная ведомость, которая о видах начислений знает куда больше любых проводок. Правило в общих инструкциях помогало плохо: в длинном контексте модель его часто пропускала. Помогла подсказка, которую сервер добавляет к ответу ровно тогда, когда видит запрос к счетам расчётов с персоналом. Похоже, модели охотнее пишут SQL, чем ищут готовый отчёт: SQL им знаком, а ваш отчёт — нет. Поэтому про отчёты полезно напоминать в тот момент, когда они нужны.&lt;/p&gt;

&lt;p&gt;Из журнала работы сервера со временем вырастает коллекция удачных запросов. Проверенные пары "вопрос — запрос" заметно улучшают следующие запросы, а самые частые шаблоны в конце концов становятся отдельными инструментами.&lt;/p&gt;

&lt;p&gt;Дороже всего обошлась защита персональных данных. Задача выглядела простой: перед отправкой в облачную модель заменить наименования контрагентов и фамилии псевдонимами. Закрыли прямой выбор поля — тестировщик достал то же значение через приведение типа, например &lt;code&gt;CAST(name AS VARCHAR(200))&lt;/code&gt;. Закрыли и это — значение пришло через временную таблицу, куда его сначала скопировали. Потом через выражение с лишним пробелом, которого не ожидал разборщик запросов. Каждая правка закрывала один способ, а тестировщик вскоре находил следующий. Маскировка поверх произвольного SQL так и остаётся эвристикой, а не гарантией, да к тому же ощутимо тормозит сервер. Если требования к конфиденциальности жёсткие, произвольный SQL лучше ограничить с самого начала и отдавать данные только через инструменты, где состав полей известен заранее.&lt;/p&gt;

&lt;p&gt;Последний урок совсем не про бухгалтерию. Однажды после обновления сервер начал падать на запросе из одной строки. Новый модуль вызывал функцию, которой не оказалось в старом, а старый, как выяснилось, обновить забыли. Контрактные тесты и проверка целостности после каждого развёртывания должны появиться в проекте в первый же день, а не после первого сбоя на рабочем сервере.&lt;/p&gt;

&lt;h2&gt;
  
  
  Заключение
&lt;/h2&gt;

&lt;p&gt;Прямой доступ модели к учётной базе даёт эффектную демонстрацию и ненадёжные цифры. SQL модель знает хорошо. Как в конкретной системе хранятся сложные проводки, аналитика, внутренние обороты и служебные записи, — не знает. Поэтому учётную логику должен считать сервер, а лучше всего штатный движок отчётов. Модели остаётся то, что у неё действительно получается: выбрать инструмент, рассудить и объяснить.&lt;/p&gt;

&lt;p&gt;Есть ли универсальный набор инструментов на все случаи жизни? К сожалению, нет. Состав инструментов зависит от ответов на несколько вопросов:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Можно ли вызвать движок отчётов системы без пользовательского интерфейса?&lt;/li&gt;
&lt;li&gt;Как хранятся проводки: парами или сторонами, и бывают ли сложные проводки?&lt;/li&gt;
&lt;li&gt;Как хранится аналитика: в универсальных колонках, в отдельной колонке на каждый вид или в многоуровневых аналитических счетах?&lt;/li&gt;
&lt;li&gt;Как формируются проводки: кодом проведения документа или настраиваемыми шаблонами типовых операций?&lt;/li&gt;
&lt;li&gt;Описывают ли метаданные системы реквизиты документов, или их придётся описывать вручную?&lt;/li&gt;
&lt;li&gt;Какие требования предъявляются к конфиденциальности данных, передаваемых в облачную модель?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;От ответов на эти вопросы зависит, какую часть учётной логики придётся перенести на сервер. И какой бы она ни оказалась, серверные расчёты нужно сверять со штатными отчётами системы: именно с ними пользователь будет сравнивать ответы модели.&lt;/p&gt;

&lt;p&gt;На этом цикл заканчивается. В &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-53a"&gt;первой части&lt;/a&gt; речь шла о выборе между частным и универсальным сервером, во &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-2-1ilo"&gt;второй&lt;/a&gt; — об ошибках модели при прямом доступе к учётной базе, в &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-3-1gf3"&gt;третьей&lt;/a&gt; — об архитектуре и инструментах сервера.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sql</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Бухгалтерия на естественном языке: каким должен быть MCP-сервер. Часть 3</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:45:49 +0000</pubDate>
      <link>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-3-1gf3</link>
      <guid>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-3-1gf3</guid>
      <description>&lt;p&gt;Во &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-2-1ilo"&gt;второй части&lt;/a&gt; мы перечислили ошибки, которые модель делает, когда сама пишет SQL к учётной базе: двойной счёт на сложных проводках, свёрнутое сальдо вместо развёрнутого, внутренние обороты в выручке, поиск не по тому объекту аналитики. Подсказками в описании таблиц их не исправить: подсказок слишком много, а таблиц сотни. В этой части описаны архитектура сервера, который берёт учётную логику на себя, его инструменты и база знаний.&lt;/p&gt;

&lt;h2&gt;
  
  
  Архитектура сервера
&lt;/h2&gt;

&lt;p&gt;Основной принцип: учётные величины вычисляет сервер. Модель выбирает инструмент, задаёт параметры на языке бухгалтера и объясняет результат. Сальдо, обороты, иерархию счетов, внутренние обороты сервер вычисляет тем же кодом, что и штатные отчёты системы.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    U[Пользователь] &amp;lt;--&amp;gt; C[LLM-клиент]
    C &amp;lt;--&amp;gt;|MCP| G[Слой ограничений:&amp;lt;br/&amp;gt;только чтение, лимиты, аудит]
    subgraph S[MCP-сервер]
        G --&amp;gt; T1[Уровень 1&amp;lt;br/&amp;gt;учётные регистры, отчёты, аналитика]
        G --&amp;gt; T2[Уровень 2&amp;lt;br/&amp;gt;поиск объектов]
        G --&amp;gt; T3[Уровень 3&amp;lt;br/&amp;gt;документы и SQL]
        G --&amp;gt; K[База знаний&amp;lt;br/&amp;gt;правила проведения, показатели]
    end
    T1 --&amp;gt; R[Движок отчётов&amp;lt;br/&amp;gt;учётной системы]
    T2 --&amp;gt; DB[(Реплика БД)]
    T3 --&amp;gt; DB
    R --&amp;gt; DB
    K -.-&amp;gt;|готовится заранее| DB
    C &amp;lt;--&amp;gt;|веб-поиск| W[Внешние данные]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Рис. 4 Архитектура: три уровня доступа к данным и база знаний.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;На первом уровне живут учётные регистры и отчёты: оборотно-сальдовая ведомость, журнал-ордер, карточка и анализ счёта, а с ними аналитические инструменты. Цифры здесь обязаны совпадать с тем, что пользователь видит в системе, копейка в копейку. Поэтому лучше всего, если сервер вызывает штатный движок отчётов, а не изобретает его заново.&lt;/p&gt;

&lt;p&gt;Второй уровень — поиск. Пользователь говорит "Ромашка" или "расчёты с поставщиками", а инструментам нужны коды и идентификаторы. Инструменты поиска сопоставляют одно с другим.&lt;/p&gt;

&lt;p&gt;Третий уровень — данные первичных документов и SQL. У каждого вида документа есть реквизиты, которые в аналитику проводок не попадают: ставка НДС, склад, условия оплаты. Без них многие вопросы остаются без ответа. Описывать вручную сотни видов документов долго, и описания быстро устаревают, поэтому описание схемы сервер строит сам, по метаданным системы.&lt;/p&gt;

&lt;p&gt;И, наконец, база знаний — то, что из схемы не выведешь: какие проводки делает каждый вид документа, какие счета входят в выручку, как устроены отчёты.&lt;/p&gt;

&lt;p&gt;Порядок выбора инструмента записывается в инструкциях сервера:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    Q[Вопрос пользователя] --&amp;gt; F[Найти объекты:&amp;lt;br/&amp;gt;счета, контрагентов, номенклатуру]
    F --&amp;gt; A{Покрывается учётным&amp;lt;br/&amp;gt;регистром или отчётом?}
    A --&amp;gt;|да| R1[Уровень 1]
    A --&amp;gt;|нет| B{Нужны реквизиты&amp;lt;br/&amp;gt;документов?}
    B --&amp;gt;|да| R2[query_documents]
    B --&amp;gt;|нет или мало| R3[run_sql с примерами&amp;lt;br/&amp;gt;и предварительной проверкой]
    R1 &amp;amp; R2 &amp;amp; R3 --&amp;gt; X{Нужно объяснить,&amp;lt;br/&amp;gt;откуда цифра?}
    X --&amp;gt;|да| K[Правила проведения,&amp;lt;br/&amp;gt;статистика проводок]
    X --&amp;gt;|нет| Ans[Ответ с периодом,&amp;lt;br/&amp;gt;организацией и источником]
    K --&amp;gt; Ans&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Рис. 5 Порядок выбора инструмента.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Инструменты
&lt;/h2&gt;

&lt;p&gt;Перечислим инструменты, которые, на наш взгляд, нужны серверу. Параметры даны схематично, в нотации TypeScript; знак &lt;code&gt;?&lt;/code&gt; отмечает необязательный параметр. Список длинный, поэтому описания инструментов должны чётко различаться: по ним модель выбирает подходящий. Какие описания и когда попадают в контекст модели, зависит от клиента.&lt;/p&gt;

&lt;p&gt;Несколько правил касаются всех инструментов сразу. Параметры задаются на языке бухгалтера: код счёта &lt;code&gt;"60.1"&lt;/code&gt;, период &lt;code&gt;"2026-Q3"&lt;/code&gt;, наименование контрагента; внутренние идентификаторы модель получает только от инструментов поиска. Итоги и сальдо считает сервер и возвращает их всегда, даже если сами строки обрезаны постраничным выводом: длинные столбцы чисел языковые модели складывают ненадёжно. В ответ включаются применённые фильтры, валюта сумм, признак учёта внутренних оборотов, дата среза и параметры перехода к детализации: из строки оборотно-сальдовой ведомости — к карточке счёта, из строки карточки — к документу. А ошибки пишутся для модели, а не для программиста: "Счёт 60.5 не найден, ближайшие: 60.1, 60.2" вместо трассировки стека.&lt;/p&gt;

&lt;p&gt;Параметры учётных регистров едины для всех форм, как и в самих учётных системах:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// "2026-09", "2026-Q3"&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LedgerParams&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;                 &lt;span class="c1"&gt;// ["60", "62"]&lt;/span&gt;
  &lt;span class="nl"&gt;include_subaccounts&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// по умолчанию true&lt;/span&gt;
  &lt;span class="nl"&gt;include_internal&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// внутренние обороты, по умолчанию false&lt;/span&gt;
  &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// { "Контрагенты": ["Ромашка"] }&lt;/span&gt;
  &lt;span class="nl"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;                &lt;span class="c1"&gt;// порядок важен: ["Подразделение", "Номенклатура"]&lt;/span&gt;
  &lt;span class="nl"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// количественные показатели&lt;/span&gt;
  &lt;span class="nl"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;national&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;both&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;company&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                   &lt;span class="c1"&gt;// организация, по умолчанию текущая&lt;/span&gt;
  &lt;span class="nl"&gt;expanded_balance&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// развёрнутое сальдо&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Уровень 1: учётные регистры и отчёты
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;get_chart_of_accounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// план счетов: код, наименование, вид (А/П/АП), объекты аналитики,&lt;/span&gt;
  &lt;span class="c1"&gt;// признаки количественного и валютного учёта, подробное описание счёта&lt;/span&gt;

&lt;span class="nf"&gt;get_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="nx"&gt;expanded_balance&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// сальдо на дату&lt;/span&gt;

&lt;span class="nf"&gt;get_trial_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LedgerParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// оборотно-сальдовая ведомость&lt;/span&gt;

&lt;span class="nf"&gt;get_journal_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LedgerParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// журнал-ордер по кредиту счёта и ведомость по дебету&lt;/span&gt;

&lt;span class="nf"&gt;get_account_card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LedgerParams&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;corr_account&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;corr_side&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;merge_identical&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// карточка (карта) счёта: проводки с документами и текущим сальдо&lt;/span&gt;

&lt;span class="nf"&gt;get_account_analysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LedgerParams&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// анализ счёта (шахматка): обороты в корреспонденции со счетами&lt;/span&gt;

&lt;span class="nf"&gt;get_general_ledger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="c1"&gt;// главная книга&lt;/span&gt;

&lt;span class="nf"&gt;list_saved_reports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;run_saved_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// настройки отчётов, которые бухгалтеры уже сохранили в системе&lt;/span&gt;

&lt;span class="nf"&gt;list_reports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;get_report_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// описание и параметры&lt;/span&gt;
&lt;span class="nf"&gt;run_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// данные, а не печатная форма&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Уровень 2: поиск объектов
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;find_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// по коду, наименованию или смыслу&lt;/span&gt;
&lt;span class="nf"&gt;find_counterparty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// наименование, УНП/ИНН, часть наименования&lt;/span&gt;
&lt;span class="nf"&gt;find_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;// номенклатура&lt;/span&gt;
&lt;span class="nf"&gt;find_document_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;find_analytic_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// все возвращают список кандидатов с признаками для различения;&lt;/span&gt;
  &lt;span class="c1"&gt;// при неоднозначности модель переспрашивает пользователя&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Уровень 3: документы и SQL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;search_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// "где хранится ставка НДС", "в каких документах есть склад"&lt;/span&gt;

&lt;span class="nf"&gt;get_document_type_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// таблицы вида документа, поля с понятными названиями,&lt;/span&gt;
  &lt;span class="c1"&gt;// ссылки на справочники, заполненность полей, шаблон запроса&lt;/span&gt;

&lt;span class="nf"&gt;query_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nx"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;only_with_entries&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// универсальный запрос к любому виду документа без написания SQL&lt;/span&gt;

&lt;span class="nf"&gt;get_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;counterparty&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;get_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// шапка, табличная часть и проводки&lt;/span&gt;

&lt;span class="nf"&gt;describe_core&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;// описание базовых представлений&lt;/span&gt;
&lt;span class="nf"&gt;get_sql_examples&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// проверенные пары "вопрос - SQL"&lt;/span&gt;
&lt;span class="nf"&gt;validate_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;// предварительная проверка без выполнения&lt;/span&gt;
&lt;span class="nf"&gt;run_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Базовых представлений — плана счетов, проводок, документов — немного, и пишутся они вручную. В представлении проводок уже обезврежены все ловушки, о которых шла речь во второй части. Как бы ни хранились проводки в системе, оно отдаёт их в двух формах: по счетам, для оборотов и сальдо, и парами корреспонденции с правильными суммами. Объекты аналитики развёрнуты в именованные колонки с наименованиями, служебные записи помечены.&lt;/p&gt;

&lt;h3&gt;
  
  
  База знаний и аналитика
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;get_posting_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// карточка правила проведения: какие проводки формирует документ и при каких условиях&lt;/span&gt;

&lt;span class="nf"&gt;get_posting_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// фактические корреспонденции счетов: сколько проводок, на какие суммы&lt;/span&gt;

&lt;span class="nf"&gt;list_metrics&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;get_metric_definition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// выручка, себестоимость, валовая прибыль...&lt;/span&gt;

&lt;span class="nf"&gt;get_pnl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;periods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;breakdown&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// отчёт о прибылях и убытках&lt;/span&gt;
&lt;span class="nf"&gt;compare_periods&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// отклонение показателя с разложением по вкладу&lt;/span&gt;
&lt;span class="nf"&gt;factor_analysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// цена, объём, структура&lt;/span&gt;
&lt;span class="nf"&gt;get_metric_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;month&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quarter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;get_top_contributors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;get_aging&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="c1"&gt;// старение дебиторской и кредиторской задолженности&lt;/span&gt;
&lt;span class="nf"&gt;get_one_off_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// разовые операции: крупные списания, резервы, курсовые разницы&lt;/span&gt;
&lt;span class="nf"&gt;export_dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// выгрузка в CSV для расчётов в клиенте&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Служебные
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;get_database_overview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// дата среза данных, организации, валюта учёта, закрытые периоды,&lt;/span&gt;
  &lt;span class="c1"&gt;// горизонт данных — то, с чего модели стоит начинать разговор&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Отдельно скажем об описаниях инструментов: на них не стоит экономить. Хорошее описание говорит не только что инструмент делает, но и когда им пользоваться, а когда нет. Именно по описанию модель решает, построить журнал-ордер штатным отчётом или сочинить свой запрос.&lt;/p&gt;

&lt;h2&gt;
  
  
  Знание предметной области
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Карточки правил проведения
&lt;/h3&gt;

&lt;p&gt;Проводки появляются по правилу, которое срабатывает при проведении (отработке) документа. Где-то это программный код обработчика в модуле документа. Где-то — типовая хозяйственная операция или образец операции: шаблон проводок с формулами сумм, который настраивает пользователь, а если формул не хватает, дописывает скрипт. Суть одна: есть правило, по которому хозяйственная операция, оформленная документом, превращается в проводки. Так не отдать ли это правило модели целиком — пусть разбирается?&lt;/p&gt;

&lt;p&gt;Не стоит. Учётная логика в коде проведения перемешана с циклами, обработкой ошибок и обращениями к объектам системы, и модели пришлось бы заново восстанавливать смысл при каждом вопросе. Удобнее один раз изложить правило в виде короткой карточки, например:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Поступление материалов от поставщика.&lt;/strong&gt; Дт 10 / Кт 60 — сумма без НДС; Дт 19 / Кт 60 — НДС. Аналитика: поставщик, договор, номенклатура, склад. Если в документе указан признак "импорт", вместо субсчёта 60.1 "Расчёты в национальной валюте" используется 60.2 "Расчёты в иностранной валюте". Если не заполнен склад, проводки не формируются.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Составляет такие карточки тоже языковая модель, но без проверки её результат мы не используем. Процесс выглядит так:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Сервер собирает для правила проведения код со всеми вызываемыми функциями, описание реквизитов документа, статистику проводок за год и два-три реальных документа.&lt;/li&gt;
&lt;li&gt;Модель пишет черновик карточки. Всё, что она не поняла однозначно, уходит в отдельное поле "неясности": додумывать запрещено.&lt;/li&gt;
&lt;li&gt;Черновик сверяется со статистикой фактических проводок.&lt;/li&gt;
&lt;li&gt;Карточка проходит проверку предсказанием.&lt;/li&gt;
&lt;li&gt;Если проверка не пройдена, карточку смотрит человек.
&lt;/li&gt;
&lt;/ol&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    C[Код проведения&amp;lt;br/&amp;gt;и зависимости] --&amp;gt; I[Сборка входа]
    M[Метаданные документа] --&amp;gt; I
    St[Статистика проводок&amp;lt;br/&amp;gt;за год] --&amp;gt; I
    Ex[2-3 реальных документа] --&amp;gt; I
    I --&amp;gt; L[LLM: черновик карточки]
    L --&amp;gt; V1{Сверка со&amp;lt;br/&amp;gt;статистикой}
    V1 --&amp;gt; V2{Проверка&amp;lt;br/&amp;gt;предсказанием}
    V2 --&amp;gt;|расхождения| H[Проверка человеком]
    V2 --&amp;gt;|ок| OK[Карточка в базе знаний]
    H --&amp;gt; OK&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Рис. 6 Подготовка карточки правила проведения.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Сверка со статистикой ловит грубые промахи: если в карточке есть корреспонденция, которой нет в данных, это либо мёртвая ветка кода, либо выдумка модели. Проверка предсказанием тоньше. Отдельный вызов модели получает только карточку и реквизиты 10–20 случайных документов и должен угадать их проводки. Если проводки не совпали, значит, в карточке чего-то не хватает.&lt;/p&gt;

&lt;p&gt;Особое внимание — полю "при каких условиях проводки не формируются". В одних системах у документа есть явный признак "проведён" или "отработан в учёте". В других его нет вовсе, и непроведённый документ изображают условием в правиле проведения. Модель в любом случае должна знать, какие документы на учёт не повлияли.&lt;/p&gt;

&lt;h3&gt;
  
  
  Определения показателей
&lt;/h3&gt;

&lt;p&gt;Для аналитики модели нужно знать, что считать выручкой, себестоимостью и прибылью в рабочем плане счетов организации. Если оставить это на её усмотрение, на один и тот же вопрос она может давать разные цифры.&lt;/p&gt;

&lt;p&gt;Пример. Выручка отражается по кредиту субсчёта 90.1 вместе с НДС, сам НДС — по дебету субсчёта 90.3 "Налог на добавленную стоимость". Кроме того, организация сдаёт в аренду часть склада, и арендная плата, раз аренда для неё не основная деятельность, идёт по кредиту субсчёта 91.1 "Прочие доходы". За III квартал по кредиту 90.1 прошло 1 200 000, по дебету 90.3 — 200 000, по кредиту 91.1 — 150 000.&lt;/p&gt;

&lt;p&gt;Финансовый директор три дня подряд спрашивает одно и то же: "Какая выручка за III квартал?"&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;День&lt;/th&gt;
&lt;th&gt;Как модель посчитала выручку&lt;/th&gt;
&lt;th&gt;Ответ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Понедельник&lt;/td&gt;
&lt;td&gt;Кредитовый оборот 90.1&lt;/td&gt;
&lt;td&gt;1 200 000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Вторник&lt;/td&gt;
&lt;td&gt;Учла НДС: кредит 90.1 минус дебет 90.3&lt;/td&gt;
&lt;td&gt;1 000 000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Среда&lt;/td&gt;
&lt;td&gt;Сочла аренду выручкой и добавила кредит 91.1&lt;/td&gt;
&lt;td&gt;1 150 000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Каждый ответ можно обосновать, и ни в одном нет ошибки SQL. Но после трёх разных чисел на один вопрос финансовый директор вряд ли поверит и четвёртому. Хуже, если в прошлом году модель считала выручку без НДС, а в этом — с НДС: она найдёт рост на 20%, которого не было, и предложит ему объяснение.&lt;/p&gt;

&lt;p&gt;С себестоимостью и прибылью вариантов ещё больше. Включать ли расходы на продажу со счёта 44 "Расходы на продажу"? Считать прибыль от продаж или до налогообложения, с прочими доходами и расходами со счёта 91 "Прочие доходы и расходы"?&lt;/p&gt;

&lt;p&gt;Мы пришли к такому решению: показатель определяют люди, один раз, а модель берёт его готовым. Хранить определения удобнее всего там, где им и место, — в описании счетов плана счетов или в отдельном справочнике показателей, в едином формате: "Показатель: Выручка; оборот: Кт 90.1 минус Дт 90.3; без внутренних оборотов". Сервер читает описания, строит справочник и заодно проверяет полноту: счёт с оборотами, не привязанный ни к одному показателю, попадает в отчёт о расхождениях.&lt;/p&gt;

&lt;p&gt;Архитектура и инструменты описаны. В &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-4-25i5"&gt;четвёртой&lt;/a&gt;, заключительной части посмотрим, как они работают на сложном вопросе вроде "почему упала прибыль", и расскажем, чему нас научила разработка: какие ошибки делала модель, какие мы сами и что мы в итоге изменили в сервере.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>llm</category>
      <category>sql</category>
    </item>
    <item>
      <title>Бухгалтерия на естественном языке: каким должен быть MCP-сервер. Часть 2</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:45:17 +0000</pubDate>
      <link>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-2-1ilo</link>
      <guid>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-2-1ilo</guid>
      <description>&lt;p&gt;В &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-53a"&gt;первой части&lt;/a&gt; мы выбрали универсальный сервер, инструменты которого параметризованы понятиями бухгалтерского учёта, и остановились на самом коротком пути к нему: дать модели описание таблиц и инструмент для выполнения SQL. Там же появился стажёр, который хорошо знает SQL, но не знает, как в базе устроен учёт. Теперь посмотрим, где именно он ошибается.&lt;/p&gt;

&lt;h2&gt;
  
  
  Условная схема данных
&lt;/h2&gt;

&lt;p&gt;Возьмём условную схему хранения проводок. Это лишь один из вариантов, другие встретятся по ходу дела.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;          &lt;span class="cm"&gt;/* план счетов */&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;parent_id&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="cm"&gt;/* счёт для субсчёта */&lt;/span&gt;
  &lt;span class="n"&gt;code&lt;/span&gt;       &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;       &lt;span class="cm"&gt;/* 60, 60.1, 90.2 ... */&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;kind&lt;/span&gt;       &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="cm"&gt;/* А, П, АП */&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;            &lt;span class="cm"&gt;/* бухгалтерская проводка */&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;doc_id&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="cm"&gt;/* первичный документ */&lt;/span&gt;
  &lt;span class="n"&gt;entry_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rule_id&lt;/span&gt;    &lt;span class="nb"&gt;INTEGER&lt;/span&gt;            &lt;span class="cm"&gt;/* правило проведения */&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;entry_line&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;       &lt;span class="cm"&gt;/* одна сторона проводки */&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;entry_id&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;side&lt;/span&gt;       &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;           &lt;span class="cm"&gt;/* D - дебет, C - кредит */&lt;/span&gt;
  &lt;span class="n"&gt;amount&lt;/span&gt;     &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;analytic1&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="cm"&gt;/* объекты аналитического учёта */&lt;/span&gt;
  &lt;span class="n"&gt;analytic2&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;analytic3&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Теперь хрестоматийная сложная проводка: от поставщика поступили материалы на 120, в том числе НДС 20. На схеме счетов, как её рисуют в учебниках, она выглядит так:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Счёт 60 "Расчёты с поставщиками
             и подрядчиками"                          Счёт 10 "Материалы"
   Дт                                Кт         Дт                                Кт
   ─────────────────┬─────────────────          ─────────────────┬─────────────────
                    │  (1) 120 ──┬─────────►     (1) 100         │
                    │            │                               │
                                 │
                                 │                  Счёт 19 "НДС по приобретённым
                                 │                           ценностям"
                                 │              Дт                                Кт
                                 │              ─────────────────┬─────────────────
                                 └─────────►     (1)  20         │
                                                                 │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Рис. 2 Сложная проводка на схеме счетов: кредит счёта 60 корреспондирует с дебетом счетов 10 и 19.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;В нашей схеме данных эта проводка лежит не одной строкой "дебет — кредит — сумма", а тремя: по строке на каждую её часть. Так удобно хранить сложные проводки с несколькими дебетуемыми или кредитуемыми счетами.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    D[Первичный документ] --&amp;gt; E[entry&amp;lt;br/&amp;gt;проводка]
    E --&amp;gt; L1[entry_line&amp;lt;br/&amp;gt;D · счёт 10 · 100]
    E --&amp;gt; L2[entry_line&amp;lt;br/&amp;gt;D · счёт 19 · 20]
    E --&amp;gt; L3[entry_line&amp;lt;br/&amp;gt;C · счёт 60 · 120]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Рис. 3 Та же проводка в базе данных: заголовок и три строки сторон.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Бывает и по-другому. Проводка хранится парой "счёт дебета — счёт кредита — сумма", хозяйственная операция состоит из заголовка и спецификации таких пар, а сложная проводка раскладывается на простые: Дт 10 Кт 60 — 100 и Дт 19 Кт 60 — 20. Для наших выводов разница невелика: ошибки возможны при обоих способах хранения, только разные.&lt;/p&gt;

&lt;h2&gt;
  
  
  Где ошибается модель
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Сложная проводка и двойной счёт
&lt;/h3&gt;

&lt;p&gt;Попросим модель построить обороты по кредиту счёта 60 в корреспонденции с другими счетами. Ответ для нашего поступления материалов (Рис. 2) очевиден: Дт 10 — 100, Дт 19 — 20, итого 120. Модель напишет примерно такой запрос:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;d_acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;debit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c_acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entry_line&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;entry_line&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entry_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entry_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'D'&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="n"&gt;c_acc&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;c_acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="n"&gt;d_acc&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d_acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'C'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;c_acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'60'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;И получит:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Дебет&lt;/th&gt;
&lt;th&gt;Кредит&lt;/th&gt;
&lt;th&gt;Сумма&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;240 вместо 120. Самосоединение размножило единственную кредитовую строку по числу дебетовых. Брать сумму пары нужно со стороны, где строк несколько, но это знание о том, как устроена конкретная система, а вовсе не о SQL. При этом такой же запрос по дебету счёта 10 даёт верный результат, а на тестовых данных, где у проводок по одной дебетовой строке, ошибка может долго оставаться незамеченной.&lt;/p&gt;

&lt;p&gt;Там, где проводки хранятся парами, этой ловушки нет. Зато есть зеркальная: сумма хозяйственной операции из заголовка повторяется в каждой строке спецификации, стоит только соединить заголовок со спецификацией. Запрос "сумма операций по поставщику" даст всё ту же удвоенную цифру.&lt;/p&gt;

&lt;h3&gt;
  
  
  Развёрнутое сальдо
&lt;/h3&gt;

&lt;p&gt;Напомним: активно-пассивные счета могут одновременно иметь дебетовое и кредитовое сальдо и показываются и в активе, и в пассиве баланса.&lt;/p&gt;

&lt;p&gt;Вопрос: "Какое сальдо по счёту 62 на конец года?" Счёт 62 "Расчёты с покупателями и заказчиками" как раз активно-пассивный. Покупатель А должен нам 100, покупатель Б перечислил аванс 30. Модель, скорее всего, вычтет кредитовые обороты из дебетовых и сообщит: дебетовое сальдо 70.&lt;/p&gt;

&lt;p&gt;Арифметически это верно, но сальдо по счетам расчётов показывают развёрнуто: дебетовое 100 (дебиторская задолженность, актив) и кредитовое 30 (полученные авансы, пассив). Свёрнутые 70 занижают и то, и другое, и баланс с такой цифрой аудитор не примет. Чтобы получить правильный ответ, остаток считают по каждому покупателю и только потом отдельно складывают положительные и отрицательные.&lt;/p&gt;

&lt;h3&gt;
  
  
  Внутренние обороты и закрытие счетов
&lt;/h3&gt;

&lt;p&gt;"Какая выручка за год?" Модель знает, что выручку отражают по кредиту счёта 90, и складывает кредитовый оборот счёта 90 со всеми субсчетами. Пусть выручка за год — 1000 (в корреспонденции со счётом 62), себестоимость — 700 (списана со счёта 41 "Товары"). В конце года субсчета 90.1 "Выручка" и 90.2 "Себестоимость продаж" закрываются на субсчёт 90.9 "Прибыль/убыток от продаж":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Дебет&lt;/th&gt;
&lt;th&gt;Кредит&lt;/th&gt;
&lt;th&gt;Сумма&lt;/th&gt;
&lt;th&gt;Смысл&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;90.1&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;выручка&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90.2&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;td&gt;себестоимость&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90.1&lt;/td&gt;
&lt;td&gt;90.9&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;закрытие 90.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90.9&lt;/td&gt;
&lt;td&gt;90.2&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;td&gt;закрытие 90.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Кредитовый оборот счёта 90 со всеми субсчетами: 1000 + 1000 + 700 = 2700. Выручка, по мнению модели, — 2700. А сальдо счёта 90 на конец года равно нулю, хотя продажи за год были.&lt;/p&gt;

&lt;p&gt;Записи между субсчетами одного счёта — это внутренние обороты. Именно поэтому учётные системы держат в отчётах флажок "включать внутренние обороты". Добавьте сюда служебные записи — перенос остатков при закрытии периода, ввод начальных остатков, — и станет понятно, почему без явных правил модель в таких вопросах ошибается регулярно.&lt;/p&gt;

&lt;h3&gt;
  
  
  Объекты аналитического учёта
&lt;/h3&gt;

&lt;p&gt;Аналитику ведут по объектам: на счёте 60 — поставщики и договоры, на счёте 10 — материалы и склады, на счёте 70 — работники. Хранят эти объекты по-разному, и у каждого способа свои ловушки.&lt;/p&gt;

&lt;p&gt;Первый способ — универсальные колонки &lt;code&gt;analytic1&lt;/code&gt;, &lt;code&gt;analytic2&lt;/code&gt;, &lt;code&gt;analytic3&lt;/code&gt;, смысл которых зависит от счёта. На счёте 60 в &lt;code&gt;analytic1&lt;/code&gt; лежит контрагент, на счёте 10 — номенклатура. Модели приходится угадывать, в какой из трёх колонок искать поставщика. Если она ошибётся, запрос "сколько мы должны Ромашке" вернёт пустую выборку. Ещё хуже, если в колонке окажется идентификатор, случайно совпавший с идентификатором Ромашки в совсем другом справочнике.&lt;/p&gt;

&lt;p&gt;Второй способ — отдельная колонка на каждый вид аналитики: "контрагент", "склад", "подразделение", а у счёта признаки, какие из них на нём ведутся. Угадывать проще. Но ничто не мешает модели взять колонку, которая на этом счёте не ведётся и заполнена как попало или пуста.&lt;/p&gt;

&lt;p&gt;Третий — многоуровневые аналитические счета: под субсчётом поставщик, под поставщиком договор. Запрос по аналитическому счёту поставщика без учёта вложенных уровней потеряет суммы, учтённые на договорах.&lt;/p&gt;

&lt;p&gt;Пустая выборка здесь опаснее синтаксической ошибки. В наших проверках модель обычно принимала её за ответ ("задолженности нет"), а не за повод перепроверить запрос.&lt;/p&gt;

&lt;h3&gt;
  
  
  Субсчета и иерархия плана счетов
&lt;/h3&gt;

&lt;p&gt;Обороты по счёту 60 — это обороты по самому счёту и всем его субсчетам. Модель напишет &lt;code&gt;WHERE code = '60'&lt;/code&gt; и ничего не найдёт: проводки делаются на субсчета. Или напишет &lt;code&gt;WHERE code LIKE '60%'&lt;/code&gt; и прихватит счёт 600, если в рабочем плане счетов организации он вдруг есть. Правильно идти по иерархии плана счетов, а как именно она хранится — ссылкой на родителя, вложенными множествами или составным путём, — модели опять же должен сказать сервер.&lt;/p&gt;

&lt;h3&gt;
  
  
  Журнал-ордер и прочие формы учётных регистров
&lt;/h3&gt;

&lt;p&gt;Пользователь просит журнал-ордер по счёту 51 "Расчётные счета". Н. П. Кондраков в учебнике "Бухгалтерский учёт" (2007, гл. 4, § 3 "Формы бухгалтерского учета") описывает журнально-ордерную форму так: "В журналы-ордера производятся только кредитовые записи того синтетического счета, операции которого учитываются в данном журнале". Дебетуемые счета идут в графах журнала-ордера как корреспондирующие, а дебетовый оборот самого счёта собирается из других журналов-ордеров. Для контроля к журналу-ордеру по счёту 51 обычно ведут ещё и ведомость по дебету. Модель же, как правило, выдаёт все обороты счёта 51 одним списком. У каждой учётной формы — оборотно-сальдовой ведомости, карточки счёта, анализа счёта, главной книги — есть устоявшееся содержание, как бы её ни называли в конкретной системе. Пользователь ждёт именно его.&lt;/p&gt;

&lt;h3&gt;
  
  
  Мелочи, которые складываются
&lt;/h3&gt;

&lt;p&gt;Список можно продолжать долго. Модель пишет конструкции привычного ей диалекта SQL, которых нет в вашей СУБД: даже ограничение числа строк в разных СУБД записывается по-разному. Придумывает колонки, которых нет, но которые "должны быть", вроде &lt;code&gt;debit_amount&lt;/code&gt; или &lt;code&gt;turnover&lt;/code&gt;. Учитывает документы, по которым проводки не формировались. Складывает рубли с долларами. Не подозревает, что в базе несколько организаций или несколько планов счетов.&lt;/p&gt;

&lt;p&gt;Каждую из этих ошибок можно поправить подсказкой в описании таблицы. Но подсказок становится много, и в длинном описании схемы модель, по нашим наблюдениям, часть из них пропускает. А таблиц в реальной учётной базе — сотни. Нужен другой подход.&lt;/p&gt;

&lt;p&gt;Каким должен быть этот подход, разберём в &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-3-1gf3"&gt;третьей части&lt;/a&gt;: как разделить работу между сервером и моделью, какие инструменты нужны серверу и откуда модель берёт знания, которых нет в схеме базы, — правила проведения и определения показателей.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>llm</category>
      <category>ai</category>
      <category>sql</category>
    </item>
    <item>
      <title>Бухгалтерия на естественном языке: каким должен быть MCP-сервер</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:44:36 +0000</pubDate>
      <link>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-53a</link>
      <guid>https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-53a</guid>
      <description>&lt;p&gt;Если бы Beatles пели не о любви, а о бухгалтерском учёте, то получилось бы что-то вроде: All you need is report. Всем от бухгалтерии интересно что-то узнать: налоговой — размер выручки, работнику — почему так мало начислили за прошлый месяц, а руководителю — начал ли хоть кто-то из хронических дебиторов рассчитываться с нами.&lt;/p&gt;

&lt;p&gt;Мы занимаемся автоматизацией бухучёта с середины девяностых. Технологии менялись, но добыча нужных цифр всегда была процессом длительным, часто грязным (кто менял ленточки на Epson-ах или досыпал тонер в картриджи HP — знает) и дорогим: надо позвать программиста, объяснить ему, какой отчёт нужен, подождать пару недель и получить на выходе не то, что ожидалось.&lt;/p&gt;

&lt;p&gt;ChatGPT изменил современный мир и то, как мы общаемся с компьютером. Пользователь больше не хочет продираться через минное поле выпадающих списков, чекбоксов и радиокнопок. Он хочет просто спросить обычным языком и получить ответ с текстом и цифрами, а если понадобится, то и с таблицами и графиками. Вопросы у пользователя могут быть как простыми: "Какое сальдо по расчётам с поставщиками на 1 октября?" или "покажи карточку счёта 50 за сентябрь", так и требующими рассуждений и анализа: "Почему упала прибыль в прошлом квартале, что происходит на рынке и что нам делать?" На простые вопросы модель обязана отвечать точно. На сложные — хотя бы обоснованно.&lt;/p&gt;

&lt;p&gt;Мостиком между моделью и учётной базой служит Model Context Protocol (MCP) — открытый протокол, через который языковая модель подключается к внешним данным и инструментам. Тем, кто застал девяностые, проще всего представить его как ODBC для нейросетей: один разъём, к которому подключается что угодно. Дело за малым — написать сервер, который будет висеть на другом конце провода. О том, каким он должен быть, и пойдёт речь.&lt;/p&gt;

&lt;p&gt;К конкретной учётной системе цикл не привязан. Схема данных условная, план счетов типовой — тот самый, по которому сдают экзамен в вузе (счёт 50 из примера выше — это, конечно, "Касса"). С терминологией сложнее: каждая система норовит назвать одно и то же по-своему. Документ где-то проводят, где-то отрабатывают в учёте, где-то формируют по нему проводки по типовой хозяйственной операции. Объекты аналитического учёта зовутся то субконто, то аналитиками, то аналитическими счетами, то классификаторами. Карточку счёта иногда величают картой, анализ счёта — шахматкой. Мы будем держаться языка учебника, а синонимы приводить там, где без них можно запутаться. Механизмы своей системы читатель, надеемся, узнает и без них.&lt;/p&gt;

&lt;h3&gt;
  
  
  План цикла
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Часть 1&lt;/strong&gt; (эта статья). Два пути: частный и универсальный сервер. Соблазн прямого доступа.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-2-1ilo"&gt;Часть 2.&lt;/a&gt;&lt;/strong&gt; Условная схема данных. Где ошибается модель.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-3-1gf3"&gt;Часть 3.&lt;/a&gt;&lt;/strong&gt; Архитектура сервера. Инструменты. Знание предметной области.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-4-25i5"&gt;Часть 4.&lt;/a&gt;&lt;/strong&gt; Сложные вопросы. Чему нас научила разработка. Заключение.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Два пути: частный и универсальный сервер
&lt;/h2&gt;

&lt;p&gt;Прежде чем браться за сервер, придётся выбрать дорогу. Их две.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Частный сервер&lt;/strong&gt; — набор инструментов под конкретные вопросы: дай дебиторов, дай список сотрудников, дай начисления по сотруднику, дай остатки на складе. Он похож на кофейный автомат: нажал кнопку "капучино" — получил капучино.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Универсальный сервер&lt;/strong&gt; устроен иначе. Его инструменты параметризованы не вопросами, а понятиями бухгалтерского учёта: счёт, период, объект аналитики, вид документа. Это уже не автомат, а бариста с кофемашиной, молоком и сиропами: из одних и тех же ингредиентов он соберёт и то, чего нет в меню.&lt;/p&gt;

&lt;p&gt;Частный сервер подкупает простотой. Написать его легко, протестировать ещё легче. Для чат-бота, через который сотрудник узнаёт остаток своего отпуска, ничего другого и не нужно. Трудности обычно появляются на втором-третьем уточняющем вопросе.&lt;/p&gt;

&lt;p&gt;Представим разговор с финансовым директором. В частном сервере есть инструменты &lt;code&gt;get_employees()&lt;/code&gt;, &lt;code&gt;get_accruals(employee)&lt;/code&gt; и &lt;code&gt;get_revenue(period)&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Вопрос&lt;/th&gt;
&lt;th&gt;Частный сервер&lt;/th&gt;
&lt;th&gt;Универсальный сервер&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Покажи начисления Иванову за сентябрь&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_accruals("Иванов")&lt;/code&gt; — ответ получен&lt;/td&gt;
&lt;td&gt;Карточка счёта 70 "Расчёты с персоналом по оплате труда" с отбором по работнику&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Сколько премий начислено отделу продаж в III квартале?&lt;/td&gt;
&lt;td&gt;Инструмента для отдела и вида начисления нет. Модель вызывает &lt;code&gt;get_accruals&lt;/code&gt; для каждого из 40 сотрудников и складывает 40 ответов в уме, путаясь в видах начислений&lt;/td&gt;
&lt;td&gt;Обороты по кредиту счёта 70 с группировкой по подразделению; вид начисления — из документов начисления заработной платы. Два вызова, итоги считает сервер&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;А по сравнению со II кварталом?&lt;/td&gt;
&lt;td&gt;Ещё 40 вызовов&lt;/td&gt;
&lt;td&gt;Тот же вызов со сравнением периодов&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Как это соотносится с выручкой отдела?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_revenue&lt;/code&gt; об отделах не знает. Ответа нет&lt;/td&gt;
&lt;td&gt;Выручка по кредиту счёта 90 "Продажи" в разрезе подразделений — показатель из справочника показателей&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Почему премии выросли, а выручка нет?&lt;/td&gt;
&lt;td&gt;Ответа нет&lt;/td&gt;
&lt;td&gt;Анализ счёта, документы начисления премий и их основания&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;На первый вопрос оба сервера отвечают одинаково. На втором у частного сервера нет подходящего инструмента, и у разработчика остаётся два варианта. Можно дописать &lt;code&gt;get_accruals_by_department_and_type&lt;/code&gt;, следом &lt;code&gt;get_revenue_by_department&lt;/code&gt;, следом ещё десяток. Чем больше похожих инструментов, тем чаще модель выбирает не тот. А можно добавлять параметры: отбор по подразделению, группировку, период сравнения. Только инструмент с отбором по любому объекту аналитики, группировкой и сравнением периодов — это уже оборотно-сальдовая ведомость. Частный сервер, если его развивать, постепенно превращается в универсальный, только без единой схемы параметров.&lt;/p&gt;

&lt;p&gt;Готовых ответов универсальный сервер при этом не лишается. Отчёт о дебиторской задолженности или расчётная ведомость по сотруднику входят в него как частные случаи: это те же учётные регистры с заданными параметрами. Разница в том, что, когда готового отчёта нет, у модели остаются инструменты, из которых его можно собрать.&lt;/p&gt;

&lt;p&gt;Дальше речь пойдёт только об универсальном сервере. Но универсальный не значит "дай модели SQL, пусть разбирается". Почему — в следующем разделе.&lt;/p&gt;

&lt;h2&gt;
  
  
  Соблазн прямого доступа
&lt;/h2&gt;

&lt;p&gt;Самый короткий путь к универсальному серверу — дать модели описание таблиц и инструмент для выполнения SQL-запросов. SQL современные модели пишут неплохо, схема базы известна. Что тут может пойти не так?&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    U[Бухгалтер] --&amp;gt;|вопрос| L[Языковая модель]
    L --&amp;gt;|SQL| S[MCP-сервер&amp;lt;br/&amp;gt;run_sql]
    S --&amp;gt; D[(Учётная база)]
    D --&amp;gt; S --&amp;gt; L --&amp;gt;|ответ| U&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;em&gt;Рис. 1 Наивная архитектура: модель сама пишет запросы к учётной базе.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Первые ответы обычно выглядят убедительно. Проблема в том, что часть из них неверна, а по виду это не определить: запрос синтаксически корректен, выполняется без ошибок и возвращает правдоподобное число. Для бухгалтерии это хуже отказа. Если число хотя бы раз разошлось с оборотно-сальдовой ведомостью, бухгалтер начинает перепроверять каждый ответ, и пользы от сервера становится немного.&lt;/p&gt;

&lt;p&gt;В чём же дело, если SQL модель знает не хуже иного программиста? Представьте толкового стажёра, которому выдали пароль от базы и ни слова не сказали о том, как в ней устроен учёт. SQL он знает, а про реформацию баланса слышит впервые. Модель находится в похожем положении, только работает быстрее и сомневается реже.&lt;/p&gt;

&lt;p&gt;Где именно стажёр спотыкается, разберём во &lt;a href="https://dev.to/andreik/bukhghaltieriia-na-iestiestviennom-iazykie-kakim-dolzhien-byt-mcp-siervier-chast-2-1ilo"&gt;второй части&lt;/a&gt;. Возьмём условную схему хранения проводок и пройдём по ошибкам, которые модель делает чаще всего: двойной счёт на сложных проводках, свёрнутое сальдо там, где нужно развёрнутое, внутренние обороты в выручке, не тот объект аналитики, субсчета. У всех этих ошибок есть общая черта: запрос выполняется без замечаний и возвращает правдоподобное число.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>llm</category>
      <category>ai</category>
      <category>sql</category>
    </item>
    <item>
      <title>Anthropic опускается, опускается Anthropic...</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sun, 16 Aug 2026 21:59:06 +0000</pubDate>
      <link>https://dev.to/andreik/anthropic-opuskaietsia-opuskaietsia-anthropic-4ifp</link>
      <guid>https://dev.to/andreik/anthropic-opuskaietsia-opuskaietsia-anthropic-4ifp</guid>
      <description>&lt;p&gt;Opus 5 стал многословным и тупым, если так можно выражаться о модели. Постоянно все забывает и путает. Сваливается в жаргон. Может спокойно предложить выполнить вот ту команду только с другим ключиком. Только саму команду не покажет, а она была где нибудь три часа назад по чату. Живет какимми то своими убеждениями и верованиями. Вместо того чтобы проверить простейшую вещь способен отправить пользователя на 1-2 часовой круг.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybhzq7umwjl5aiw73r0f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybhzq7umwjl5aiw73r0f.png" alt="opus 5 is dumb" width="779" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Как-то фирма Ashton-Tate правила миром через свою СУБД dBase III, но потом выпустила глючный dBase IV и была мигом сожрана конкурентами. О таком подарке FoxPro с Clipper-ом даже и мечтать не могли. &lt;/p&gt;

&lt;p&gt;Если Anthropic не подсуетится, то китайцы с одной стороны, а одесское пароходство (зачеркнуто) openai -- с другой, его просто аннигилируют. Тем более что словоблудный Opus 5 только треть их проблем, вкупе с отсутствием дешевой модели с ризонингом и маленьким входящим и исходящим буфером haiku.&lt;/p&gt;

&lt;p&gt;PS: стоило только написать и минуту спустя:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F979bcm9l6oer3q0h1qj7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F979bcm9l6oer3q0h1qj7.png" alt="Anthropic troubles" width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;В приличных домах за такое дают внеочередной резет.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>What is Cursor AI’s business model? Asking for a friend.</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Mon, 27 Apr 2026 21:12:37 +0000</pubDate>
      <link>https://dev.to/andreik/what-is-cursor-ais-business-model-asking-for-a-friend-1jdj</link>
      <guid>https://dev.to/andreik/what-is-cursor-ais-business-model-asking-for-a-friend-1jdj</guid>
      <description>&lt;p&gt;Other big players may have collateral incentives. OpenAI &amp;amp; Claude has much broader goals for its AI. Microsoft has a whole range of technologies, plus Azure, to lock developers into its ecosystem. But what about Cursor?&lt;/p&gt;

&lt;p&gt;The more it progresses, the fewer human programmers will be left, which would eventually eliminate subscription revenue. Are they on an audacious path? Or do their business plans envision a bright future in which the world produces and uses 100x or even 1000x more software?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cursor</category>
      <category>openai</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>Отказоустойчивые видеозвонки на WebRTC</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sun, 26 Apr 2026 09:51:30 +0000</pubDate>
      <link>https://dev.to/andreik/otkazoustoichivyie-vidieozvonki-na-webrtc-cln</link>
      <guid>https://dev.to/andreik/otkazoustoichivyie-vidieozvonki-na-webrtc-cln</guid>
      <description>&lt;p&gt;&lt;em&gt;Как мы спроектировали, реализовали и проверили в реальных условиях модуль видеосвязи для видеоинтервью и самостоятельных видеотестов в &lt;a href="https://recruiter-ai.andersenlab.com/" rel="noopener noreferrer"&gt;Recruiter.AI&lt;/a&gt; — и какие технические проблемы пришлось решить по пути.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Сразу обозначим рамки статьи. WebRTC — большая тема, по которой написаны целые книги. Это не учебник по WebRTC, а разбор конкретной архитектуры и инженерных решений, которые мы приняли для видеозвонков в Recruiter.AI.&lt;/p&gt;

&lt;p&gt;Код видеозвонков доступен в &lt;a href="https://github.com/gsbelarus/gdmn-meet" rel="noopener noreferrer"&gt;репозитории GDMN Meet&lt;/a&gt;. Его можно попробовать на &lt;a href="https://meet.gdmn.app" rel="noopener noreferrer"&gt;meet.gdmn.app&lt;/a&gt; или увидеть в работе в &lt;a href="https://recruiter-ai.andersenlab.com/" rel="noopener noreferrer"&gt;сценарии интервью Recruiter.AI&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Почему понадобилось свое решение
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://recruiter-ai.andersenlab.com/" rel="noopener noreferrer"&gt;Recruiter.AI&lt;/a&gt; от &lt;strong&gt;Andersen Lab&lt;/strong&gt; — платформа для найма с поддержкой ИИ. Две ее ключевые функции — &lt;strong&gt;видеоинтервью&lt;/strong&gt; между кандидатом и интервьюером и &lt;strong&gt;самостоятельный видеотест&lt;/strong&gt;, где кандидат записывает ответы на вопросы один перед камерой, — зависят от видеопотока, который должен стабильно работать не только в идеальных условиях.&lt;/p&gt;

&lt;p&gt;Пользователь может подключаться из дома, из гостиницы, через нестабильный мобильный интернет, за корпоративным файрволом или из сети с национальной DPI-фильтрацией. Для такой среды недостаточно просто вызвать WebRTC API и рассчитывать, что браузер все сделает сам.&lt;/p&gt;

&lt;p&gt;В статье разберем:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;общую архитектуру и причины, по которым она устроена именно так;&lt;/li&gt;
&lt;li&gt;собственный минимальный сигнальный сервер на Socket.IO;&lt;/li&gt;
&lt;li&gt;STUN, TURN и &lt;strong&gt;проблему юрисдикций&lt;/strong&gt;, в том числе ограничения сетей, фильтруемых российским РКН;&lt;/li&gt;
&lt;li&gt;паттерн «идеального согласования» (Perfect Negotiation) и буферизацию ICE-кандидатов;&lt;/li&gt;
&lt;li&gt;многоуровневое восстановление соединения для нестабильных сетей;&lt;/li&gt;
&lt;li&gt;обнаружение замершего медиапотока, когда браузер все еще считает соединение активным;&lt;/li&gt;
&lt;li&gt;различия между Chrome, Firefox и Safari и конкретные обходные решения;&lt;/li&gt;
&lt;li&gt;проверку оборудования и горячую замену камер и микрофонов во время звонка;&lt;/li&gt;
&lt;li&gt;конвейер записи: компоновку через &lt;code&gt;canvas&lt;/code&gt;, загрузку фрагментами и исправление для Safari, на поиск которого ушло два дня;&lt;/li&gt;
&lt;li&gt;инструменты диагностики, которые помогают проверять все это в реальных сетях.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Общая картина
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2c3srfzb0q321692mufp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2c3srfzb0q321692mufp.png" alt="Обзор архитектуры: два браузера, Socket.IO для сигнального обмена, STUN, TURN поверх TLS в нескольких юрисдикциях и загрузка записи фрагментами" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;На верхнем уровне в системе есть четыре плоскости:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Медиаплоскость (P2P).&lt;/strong&gt; Два &lt;code&gt;RTCPeerConnection&lt;/code&gt; обмениваются зашифрованным DTLS-SRTP-трафиком. При удачном сценарии медиаданные идут напрямую между браузерами, без промежуточного сервера.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Плоскость сигнального обмена.&lt;/strong&gt; Минимальный Socket.IO-сервер на стороне приложения пересылает типизированные сообщения между участниками комнаты и хранит только минимальное состояние присутствия, нужное для защиты от повторных входов.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ICE-плоскость.&lt;/strong&gt; STUN-серверы Google, Cloudflare и наш собственный coturn помогают проходить NAT. Если прямое P2P-соединение невозможно, медиатрафик идет через TURN-серверы поверх TLS на порту 443.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Приемник записи.&lt;/strong&gt; API загрузки фрагментами принимает 30-секундные медиаблоки, которые создает &lt;code&gt;CallRecorder&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;На клиенте ответственность разделена между двумя хуками, связанными небольшим интеграционным слоем. Первый управляет &lt;code&gt;RTCPeerConnection&lt;/code&gt;, сигнальным обменом и восстановлением соединения. Второй отвечает за камеры, микрофоны, подключение и отключение устройств, а также за локальный медиапоток. Такое разделение оказалось важным: замена устройств во время звонка сама по себе достаточно сложна, и смешивать ее с состоянием сигнального обмена было бы ошибкой.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Сигнальный сервер: минимальный, восстанавливаемый, свой
&lt;/h2&gt;

&lt;p&gt;Для сигнального обмена мы используем точку подключения Socket.IO как часть сервера приложения. У нее три задачи:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Пересылать сообщения.&lt;/strong&gt; Сервер не хранит состояние звонка, а только передает сообщения между участниками. Благодаря этому протокол можно развивать без изменений на серверной стороне.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Восстанавливать сессию после краткого обрыва.&lt;/strong&gt; Если у кандидата на несколько секунд пропал Wi-Fi, страница не должна требовать ручной перезагрузки.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Отслеживать присутствие в минимальном объеме.&lt;/strong&gt; Сервер должен понимать, кто находится в комнате, чтобы отклонять повторный вход одного и того же участника.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Именно второй пункт стал причиной, по которой мы выбрали Socket.IO, а не обычный WebSocket. В Socket.IO есть &lt;code&gt;connectionStateRecovery&lt;/code&gt;: механизм буферизует события в течение заданного окна и восстанавливает сессию, если клиент успевает переподключиться. У нас это окно равно &lt;strong&gt;пяти минутам&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Сам протокол компактный: девять типов сообщений — JOIN/LEAVE, ENUM, CALLING, ANSWER_CALL, SIGNAL, SOCKET_PING/PONG, RESTART_CALL, END_CALL, MAKE_CALL. &lt;code&gt;SIGNAL&lt;/code&gt; передает и SDP, и ICE-кандидаты. В каждом сообщении есть &lt;code&gt;fromId&lt;/code&gt;/&lt;code&gt;toId&lt;/code&gt;, поэтому серверу не нужно хранить сведения о том, кто именно с кем разговаривает.&lt;/p&gt;

&lt;p&gt;Последовательность успешной установки звонка:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt8oit0vt2ande6dmeun.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt8oit0vt2ande6dmeun.png" alt="Последовательность сигнального обмена: JOIN, CALLING, предложение/ответ (offer/answer), постепенная передача ICE-кандидатов и пульсовая проверка через SOCKET_PING/PONG" width="800" height="1471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;В начальной фазе подключения есть две важные детали:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Каждая попытка подключения отправляет новый &lt;code&gt;instanceId&lt;/code&gt; (UUID v4) вместе с &lt;code&gt;participantId&lt;/code&gt;. Если вторая вкладка входит с тем же &lt;code&gt;participantId&lt;/code&gt;, сервер отвечает &lt;code&gt;already_in_room&lt;/code&gt;, и вторая вкладка прекращает подключение. Так мы обнаруживаем и отклоняем &lt;strong&gt;дублирующиеся входы&lt;/strong&gt; — частую причину жалоб вроде «я вижу себя в сетке два раза».&lt;/li&gt;
&lt;li&gt;При закрытии вкладки клиент отправляет LEAVE через &lt;code&gt;navigator.sendBeacon()&lt;/code&gt;. &lt;code&gt;window.onbeforeunload&lt;/code&gt; в мобильных браузерах ненадежен; &lt;code&gt;sendBeacon&lt;/code&gt; работает лучше.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. STUN, TURN и проблема юрисдикций
&lt;/h2&gt;

&lt;p&gt;Получить ICE-кандидаты сравнительно просто: достаточно указать для &lt;code&gt;RTCPeerConnection&lt;/code&gt; несколько публичных STUN-серверов. Конфигурация по умолчанию:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;stun:stun.l.google.com:19302&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stun:stun.cloudflare.com:3478&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;наш собственный сервер для резервирования&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Сложная часть — TURN. Существенная доля пользователей не может установить прямое P2P-соединение: корпоративные сети, симметричные NAT, мобильные операторы с NAT операторского класса (carrier-grade NAT) и, что особенно важно для Recruiter.AI, пользователи внутри национально фильтруемых сетей. В таких случаях медиатрафик должен идти через промежуточный TURN-сервер.&lt;/p&gt;

&lt;p&gt;Поэтому мы подняли собственный &lt;strong&gt;coturn&lt;/strong&gt; и открыли TURN на порту &lt;strong&gt;443&lt;/strong&gt;. URL ICE-сервера выглядит так:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;turns:coturn.our_server.com:443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Порт 443 важен. Многие корпоративные файрволы и национальные DPI-фильтры пропускают TLS по TCP/443, потому что блокировка этого трафика нарушит работу HTTPS. TURN-сервер на этом порту, работающий внутри корректного TLS-туннеля, для большинства DPI выглядит как обычная HTTPS-сессия. Поэтому мы используем &lt;strong&gt;TURN поверх TLS на :443&lt;/strong&gt;, а не стандартные транспорты UDP 3478 или TCP 5349.&lt;/p&gt;

&lt;p&gt;Но при работе с пользователями из стран с жесткой фильтрацией трафика возникает еще одна проблема.&lt;/p&gt;

&lt;h3&gt;
  
  
  Проблема российской фильтрации
&lt;/h3&gt;

&lt;p&gt;Российская инфраструктура национальной фильтрации трафика — система РКН, включая ТСПУ на точках пиринга провайдеров, — блокирует не только &lt;em&gt;известные&lt;/em&gt; адреса. Соединения могут прерываться и для узлов, чьи IP-адреса или SNI-имена попали в списки фильтрации. Кроме того, фильтрация становится все агрессивнее к трафику, который &lt;em&gt;похож&lt;/em&gt; на WebRTC: долгоживущим UDP-потокам или характерным STUN-пакетам поверх TCP. TURN-сервер, который безупречно работает из Амстердама, может за ночь стать недоступным из Москвы, если соседний сервис из того же IP-диапазона попал под фильтрацию.&lt;/p&gt;

&lt;p&gt;Практический вывод: &lt;strong&gt;одного TURN-развертывания, даже качественного, недостаточно&lt;/strong&gt;. Мы стали размещать TURN-серверы в разных юрисдикциях, чтобы у пользователей из конкретного региона всегда был доступный сервер-посредник с IP-адресом и hostname, которые не блокируются на их сетевом маршруте. Конфигурация WebRTC вычисляется для каждой организации во время выполнения через внутренний реестр сервисов, а конфигурация из переменных окружения служит запасным вариантом. Это позволяет направлять клиентов в подходящий TURN-пул без нового развертывания.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbgbixl2r9t99otyf2p93.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbgbixl2r9t99otyf2p93.png" alt="TURN в достижимой юрисдикции передает медиатрафик по TLS :443 для пользователя за национальным DPI-фильтром" width="800" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Сборка конфигурации
&lt;/h3&gt;

&lt;p&gt;Сборщик конфигурации превращает JSON из переменных окружения в &lt;code&gt;RTCConfiguration&lt;/code&gt;. Он небольшой, но ошибки в значениях по умолчанию здесь приводят к особенно неприятным последствиям, поэтому мы сделали его строгим:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getRTCConfiguration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;stun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;icePolicy&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;WebRTCConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;RTCConfiguration&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RTCConfiguration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;stun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;iceTransportPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;icePolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;icePolicy&lt;/code&gt; настраивается: по умолчанию это &lt;code&gt;'all'&lt;/code&gt;, но на уровне конфигурации его можно переключить в &lt;code&gt;'relay'&lt;/code&gt;. Это удобно для отладки и для клиентов, которым по юридическим причинам нужен единый маршрут через TURN. Код звонка при этом не меняется.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Идеальное согласование (Perfect Negotiation) с буферизацией ICE
&lt;/h2&gt;

&lt;p&gt;Оба участника могут одновременно попытаться создать предложение соединения (offer). В WebRTC такая коллизия называется конфликтом предложений (glare). Паттерн W3C &lt;a href="https://w3c.github.io/webrtc-pc/#perfect-negotiation-example" rel="noopener noreferrer"&gt;Perfect Negotiation&lt;/a&gt; назначает одну сторону «вежливой» (polite peer) и позволяет ей откатить собственное локальное описание, если входящее предложение конфликтует с уже созданным локальным предложением. Мы реализуем этот подход через стандартные флаги &lt;code&gt;makingOffer&lt;/code&gt;, &lt;code&gt;ignoreOffer&lt;/code&gt; и &lt;code&gt;polite&lt;/code&gt;. Вежливой стороной у нас выступает принимающий участник.&lt;/p&gt;

&lt;p&gt;Важная практическая деталь: ICE-кандидаты могут прийти &lt;em&gt;раньше&lt;/em&gt;, чем будет установлено удаленное описание (&lt;code&gt;remoteDescription&lt;/code&gt;). Это особенно часто проявляется в медленных сетях: SDP доходит с задержкой, а ICE-кандидаты начинают поступать почти сразу. В современных браузерах вызов &lt;code&gt;addIceCandidate&lt;/code&gt; до &lt;code&gt;setRemoteDescription&lt;/code&gt; приводит к ошибке. Поэтому мы &lt;strong&gt;буферизуем&lt;/strong&gt; ICE-кандидаты для каждого участника и очищаем буфер только после успешного &lt;code&gt;setRemoteDescription&lt;/code&gt;. На практике в буфере обычно всего несколько кандидатов, но без этого механизма звонок на нестабильных сетях может не установиться без явной ошибки.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Восстановление: шаг за шагом
&lt;/h2&gt;

&lt;p&gt;Эта часть потребовала больше всего итераций. WebRTC дает &lt;code&gt;connectionState&lt;/code&gt;, &lt;code&gt;iceConnectionState&lt;/code&gt;, &lt;code&gt;iceGatheringState&lt;/code&gt;, &lt;code&gt;signalingState&lt;/code&gt;, но ни одно из этих состояний нельзя считать исчерпывающим источником правды. Соединение с &lt;code&gt;connectionState === 'connected'&lt;/code&gt; все еще может передавать ноль байт, если сетевой маршрут перестал пропускать трафик. А состояние &lt;code&gt;iceConnectionState === 'disconnected'&lt;/code&gt; иногда восстанавливается само через секунду, если дать браузеру время.&lt;/p&gt;

&lt;p&gt;Поэтому мы объединили несколько сигналов и сделали ступенчатую схему восстановления с пятью порогами:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heartbeatDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heartbeatThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;heartbeatDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 5 s&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statsDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;// full restart&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iceRestartDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;//  9 s — ICE restart&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoWarmUpDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waitingForInternetDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// give up&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Схема выглядит так:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feewli769y5pim2t7eg2i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feewli769y5pim2t7eg2i.png" alt="Машина состояний восстановления: Healthy, PreProblem, Degraded, IceRestart, FullRestart, WaitInternet и Ended" width="800" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;В текстовом виде это работает так:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Каждые 2,5 секунды&lt;/strong&gt; каждая сторона отправляет &lt;code&gt;SOCKET_PING&lt;/code&gt;, а вторая отвечает &lt;code&gt;SOCKET_PONG&lt;/code&gt;. Два пропуска подряд, то есть 5 секунд, означают, что путь через сигнальный сервер, скорее всего, недоступен.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Примерно каждые 5 секунд&lt;/strong&gt; вызывается &lt;code&gt;pc.getStats()&lt;/code&gt;, после чего сравниваются счетчики RTP-байтов. Если не изменились ни входящие, ни исходящие байты, медиапоток считается замершим независимо от состояния WebRTC.&lt;/li&gt;
&lt;li&gt;При &lt;strong&gt;первом&lt;/strong&gt; признаке проблемы (&lt;code&gt;connectionState === 'failed'&lt;/code&gt;, ICE disconnected/closed, неактивный медиапоток или отсутствие передачи данных) мы сохраняем отметку &lt;code&gt;preProblemDiscovered&lt;/code&gt;. Если на следующем цикле проблема сохраняется, она повышается до &lt;code&gt;problemDiscovered&lt;/code&gt;. Такая задержка на один цикл убрала большую часть ложных срабатываний в тестах.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Через 9 секунд&lt;/strong&gt; подтвержденной проблемы, если соединение с сигнальным сервером живо и текущая сторона является инициатором звонка, вызывается &lt;code&gt;pc.restartIce()&lt;/code&gt;. Это запускает повторное ICE-согласование без пересоздания &lt;code&gt;RTCPeerConnection&lt;/code&gt;: операция требует мало ресурсов и обычно выполняется быстро.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Через 18 секунд&lt;/strong&gt; выполняется полный &lt;code&gt;restartCall()&lt;/code&gt;: текущий &lt;code&gt;RTCPeerConnection&lt;/code&gt; закрывается, через сокет отправляется &lt;code&gt;RESTART_CALL&lt;/code&gt;, вторая сторона также очищает свое состояние, после чего соединение создается заново. Это более ресурсоемкий, но надежный вариант восстановления.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Если недоступен сам сокет&lt;/strong&gt; или &lt;code&gt;navigator.onLine === false&lt;/code&gt;, перезапуск не запускается: для него все равно нет рабочего канала управления. В этом состоянии мы ждем до 60 секунд; только после этого интерфейс показывает сообщение: «Connection lost. Please check internet connection.»&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;У этой логики много условий и переходов, поэтому мы многократно проверяли ее в условиях искусственно ограниченной сети. Ключевой блок из цикла проверки статистики:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onLine&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;missedHeartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socketPingReceived&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;waitingForInternetDelay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;endCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Connection lost. Please check internet connection.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;endCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;updateParticipant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;healthCheck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;caller&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;restartCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// caller must manage the call, but if it avoids its responsibilities...&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;waitingForInternetDelay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;endCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;updateParticipant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;healthCheck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Есть два важных ограничения:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Перезапуск инициирует только вызывающая сторона.&lt;/strong&gt; Если обе стороны одновременно начнут пересоздавать соединение, они будут постоянно конфликтовать на уровне SDP. Принимающая сторона реагирует на сигнал перезапуска, но сама его не запускает. Если же инициатор перестал выполнять эту роль — например, браузер заморозил его вкладку, — принимающая сторона завершает звонок самостоятельно после более длинного таймаута (&lt;code&gt;waitingForInternetDelay + recoveryDelay = 78 s&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Время «с момента проблемы» считается от &lt;code&gt;Math.max(problemDiscovered, lastSocketConnected, socketPingRestored, lastIceStateChange)&lt;/code&gt;. Если сокет кратко восстановился или ICE-состояние на секунду вернулось в норму, таймер восстановления сбрасывается. Без этого звонок можно было бы завершить уже после того, как кратковременный сбой фактически прошел.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Как мы находим замерший медиапоток
&lt;/h2&gt;

&lt;p&gt;Один из самых полезных фрагментов кода в проекте оказался очень небольшим. Мы отслеживаем изменение счетчиков RTP-байтов из &lt;code&gt;pc.getStats()&lt;/code&gt;. Если за полный интервал проверки не изменились ни отправленные, ни полученные байты, соединение считается неработоспособным независимо от значения &lt;code&gt;iceConnectionState&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDataTransmitted&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Participant&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statInterval&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesTime&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevReceived&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevTime&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesSent&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevSent&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deltaTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesTime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deltaReceived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevReceived&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deltaSent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesSent&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevSent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// only judge if there were enough time to transmit some data&lt;/span&gt;
    &lt;span class="c1"&gt;// Connection is alive if EITHER data is being received OR sent (not both required)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;deltaTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;statsDelay&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deltaReceived&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;deltaSent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Проверка &lt;code&gt;deltaReceived &amp;gt; 0 || deltaSent &amp;gt; 0&lt;/code&gt;, а не &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, была осознанным решением. У одного из участников может быть выключен микрофон, а часть медиаданных может временно идти только в одном направлении. Если считать соединение живым только при одновременном росте обоих счетчиков, система будет ошибочно завершать нормальные звонки. Поэтому для проверки достаточно, чтобы данные передавались хотя бы в одну сторону.&lt;/p&gt;

&lt;p&gt;Также есть короткий запас времени на появление первого медиапотока после установки звонка (&lt;code&gt;videoWarmUpDelay = 8_000&lt;/code&gt;). Некоторые браузеры, особенно Safari, заметно задерживают первый видеокадр даже после &lt;code&gt;connectionState === 'connected'&lt;/code&gt;. Если проверять отсутствие видео раньше восьми секунд, система будет выдавать ложные срабатывания.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Браузерные войны: Chrome, Firefox, Safari
&lt;/h2&gt;

&lt;p&gt;Больше всего времени в проекте заняли различия между браузерами. Вот конкретные случаи, которые стоит зафиксировать.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safari: &lt;code&gt;MediaRecorder&lt;/code&gt; требует &lt;code&gt;timeSlice&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;В Chrome и Firefox &lt;code&gt;MediaRecorder.start()&lt;/code&gt; без аргументов возвращает один большой &lt;code&gt;Blob&lt;/code&gt; при вызове &lt;code&gt;stop()&lt;/code&gt;; если передать &lt;code&gt;timeSlice&lt;/code&gt; в миллисекундах, данные будут приходить периодически. В Safari вызов &lt;code&gt;start()&lt;/code&gt; без &lt;code&gt;timeSlice&lt;/code&gt; может не отдавать данные вообще. При этом рекордер выглядит рабочим: нет ошибок, промисы не отклоняются, но &lt;code&gt;ondataavailable&lt;/code&gt; не приносит полезных данных.&lt;/p&gt;

&lt;p&gt;На этом мы потеряли два дня в сценарии самостоятельного видеотеста. Исправление — флаг &lt;code&gt;useTimeSlice&lt;/code&gt;, включаемый только для нужного браузера, в обоих классах рекордера:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useTimeSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browserName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Safari&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;…&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useTimeSlice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIME_SLICE&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIME_SLICE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1000 ms&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Есть и второй уровень проблемы: на границе 30-секундного фрагмента рекордер останавливается и запускается снова. В Safari данные снова появляются только если передавать &lt;code&gt;timeSlice&lt;/code&gt; в &lt;code&gt;start()&lt;/code&gt; &lt;em&gt;при каждом запуске&lt;/em&gt;. Поэтому тот же флаг используется и при перезапуске рекордера.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firefox: названия устройств пустые, пока не запросишь доступ
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;navigator.mediaDevices.enumerateDevices()&lt;/code&gt; возвращает &lt;code&gt;label: ""&lt;/code&gt; для каждого устройства, пока пользователь хотя бы раз не выдал доступ через &lt;code&gt;getUserMedia()&lt;/code&gt;. Chrome может заполнить подписи до запроса, если сайт уже был авторизован раньше; Firefox — нет. Поэтому мы сначала перечисляем устройства, затем запрашиваем доступ, после чего &lt;strong&gt;перечисляем устройства повторно&lt;/strong&gt; и показываем обновленные подписи в списках выбора.&lt;/p&gt;

&lt;h3&gt;
  
  
  Выбор MIME
&lt;/h3&gt;

&lt;p&gt;Chrome и Firefox предпочитают WebM: VP8/VP9 для видео и Opus для аудио. Safari надежно кодирует только MP4 с AVC и AAC. Мы при загрузке проверяем поддержку и выбираем лучшую доступную комбинацию, а для Safari запасным вариантом используем &lt;code&gt;'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'&lt;/code&gt;. Это решение влияет и на расширение загружаемого файла (&lt;code&gt;.webm&lt;/code&gt; или &lt;code&gt;.mp4&lt;/code&gt;), по которому серверная часть выбирает нужный конвейер последующей обработки.&lt;/p&gt;

&lt;h3&gt;
  
  
  iOS
&lt;/h3&gt;

&lt;p&gt;На iOS в ограничениях для видео мы всегда указываем &lt;code&gt;facingMode: 'user'&lt;/code&gt;, чтобы по умолчанию открывалась фронтальная камера. &lt;code&gt;deviceId: { exact: … }&lt;/code&gt; на iOS иногда выбирает не тот объектив, который ожидает пользователь. Это был небольшой, но устойчивый источник ошибок, пока мы не стандартизировали поведение через &lt;code&gt;facingMode&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Оборудование: проверить, заменить, восстановить
&lt;/h2&gt;

&lt;p&gt;Интервью и самостоятельные видеотесты — это &lt;em&gt;записываемая&lt;/em&gt; оценка реальных людей, поэтому оборудование должно работать правильно. Здесь помогают три вещи.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.1 Проверка микрофона перед звонком
&lt;/h3&gt;

&lt;p&gt;Перед входом в звонок пользователь проходит проверку звука в модальном окне с живым индикатором громкости. Мы создаем &lt;code&gt;AudioContext&lt;/code&gt; + &lt;code&gt;AnalyserNode&lt;/code&gt; на отдельном потоке &lt;code&gt;getUserMedia({ audio: { deviceId: { exact } } })&lt;/code&gt;, снимаем частотные данные, отслеживаем пиковый уровень в dB и сравниваем его с порогом −35 dB. Если микрофон пользователя не достигает этого порога, мы предупреждаем его до начала звонка, а не тогда, когда интервьюер уже ждет.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.2 Горячая замена во время звонка
&lt;/h3&gt;

&lt;p&gt;Если кандидат подключил USB-гарнитуру во время звонка, соединение не должно прерываться. Для этого нужны две вещи:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Хук медиаустройств слушает &lt;code&gt;navigator.mediaDevices.ondevicechange&lt;/code&gt; с задержкой в 1 секунду: при подключении устройство часто порождает несколько событий подряд.&lt;/li&gt;
&lt;li&gt;Когда пользователь выбирает новое устройство, &lt;code&gt;RTCPeerConnection&lt;/code&gt; не пересогласуется. Мы заменяем трек через &lt;code&gt;RTCRtpSender.replaceTrack()&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// useVideoCall switchTrack (wired from useMediaDevices)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;participantsRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in-call&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSenders&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;newTrack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTrack&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no renegotiation&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;localStreamRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;replaceTrack&lt;/code&gt; здесь именно тот инструмент, который нужен: новое согласование по схеме «предложение/ответ» (offer/answer) не требуется, удаленная сторона видит непрерывный трек, а рекордер, если он уже работает, продолжает запись.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.3 Восстановление, когда устройство исчезло
&lt;/h3&gt;

&lt;p&gt;Если активная камера или микрофон &lt;em&gt;исчезают&lt;/em&gt; — пользователь отключил USB-устройство, разрешение было отозвано на уровне ОС или браузер выбрал другое устройство, — включается двухступенчатое восстановление:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Мягкое восстановление.&lt;/strong&gt; &lt;code&gt;track.onended&lt;/code&gt; помечает тип устройства как требующий замены. Затем срабатывает обработчик &lt;code&gt;devicechange&lt;/code&gt; с задержкой, мы заново перечисляем устройства, выбираем первое доступное устройство нужного типа и вызываем &lt;code&gt;switchDevices&lt;/code&gt; через тот же путь &lt;code&gt;replaceTrack&lt;/code&gt;, что и при обычной ручной замене. Для удаленного участника звонок продолжается без заметного разрыва.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Жесткое восстановление.&lt;/strong&gt; Если замены нет — все камеры отключены или разрешения отозваны, — локальный медиапоток корректно останавливается, сохраняется контекст восстановления со старыми и новыми device IDs, а наверх поднимается типизированная ошибка (&lt;code&gt;isNotAllowedError&lt;/code&gt;, &lt;code&gt;isNotFoundError&lt;/code&gt;, &lt;code&gt;isNotReadableError&lt;/code&gt;, &lt;code&gt;isOverconstrainedError&lt;/code&gt;). Ее принимает отдельный интерфейс для ошибок медиаустройств и показывает правильное действие: «Разрешите доступ к камере», «Подключите камеру», «Закройте другое приложение, которое использует камеру» и так далее.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Есть еще одна важная деталь: виртуальный фон создает &lt;strong&gt;синтетический трек&lt;/strong&gt; с искусственным &lt;code&gt;deviceId&lt;/code&gt;. Если записать этот ID обратно в состояние выбранной камеры, в следующий раз пользователь откроет настройки и увидит UUID, который не соответствует ни одному реальному устройству. Исправление — разделить &lt;strong&gt;выбор пользователя&lt;/strong&gt; (&lt;code&gt;selectedCameraId&lt;/code&gt;) и &lt;strong&gt;текущее рабочее состояние&lt;/strong&gt; (&lt;code&gt;currentCamera&lt;/code&gt;). Когда виртуальный фон включен, мы сохраняем исходный ID выбранной камеры, а не ID синтетического трека.&lt;/p&gt;

&lt;p&gt;Все операции с устройствами сериализованы через &lt;code&gt;Semaphore&lt;/code&gt;, чтобы быстрые клики по выпадающему списку устройств не создавали перекрывающиеся &lt;code&gt;getUserMedia&lt;/code&gt;-вызовы и брошенные треки.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Конвейер записи
&lt;/h2&gt;

&lt;p&gt;У записи был собственный набор сложностей: видеозвонок &lt;em&gt;многопользовательский&lt;/em&gt; — интервьюер, кандидат, иногда больше людей, — а на выходе нужен один удобный для просмотра артефакт.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2964xxugv74rp0dzzi9f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2964xxugv74rp0dzzi9f.png" alt="Конвейер записи: компоновка видео через canvas, отдельные аудиорекордеры по участникам, исправление для Safari с timeSlice и загрузка фрагментами" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Для звонка (&lt;code&gt;CallRecorder&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; каждого участника отрисовывается в &lt;strong&gt;сетку 2×2 на canvas&lt;/strong&gt; на каждом кадре анимации. &lt;code&gt;canvas.captureStream()&lt;/code&gt; превращает результат в &lt;code&gt;MediaStream&lt;/code&gt; с одним составным видеотреком.&lt;/li&gt;
&lt;li&gt;Этот составной поток передается в один &lt;code&gt;MediaRecorder&lt;/code&gt; для видео.&lt;/li&gt;
&lt;li&gt;Параллельно аудиотрек каждого участника передается в &lt;strong&gt;свой&lt;/strong&gt; &lt;code&gt;MediaRecorder&lt;/code&gt;. Раздельное аудио упрощает последующую транскрибацию по спикерам — не нужно разделять уже смешанный звук — и позволяет не сводить WebRTC-аудиотреки на лету с потерей качества.&lt;/li&gt;
&lt;li&gt;Каждые 30 секунд рекордеры переключаются на новый фрагмент: &lt;code&gt;stop()&lt;/code&gt; отдает последний &lt;code&gt;Blob&lt;/code&gt; через &lt;code&gt;ondataavailable&lt;/code&gt;, затем &lt;code&gt;start(TIME_SLICE)&lt;/code&gt; начинает следующий фрагмент. Каждый &lt;code&gt;Blob&lt;/code&gt; сразу загружается с &lt;code&gt;interviewId&lt;/code&gt;, &lt;code&gt;participantId&lt;/code&gt;, &lt;code&gt;timestamp&lt;/code&gt; и расширением медиатипа. Если загрузка не удалась, повторяется отправка только этого фрагмента: один сетевой сбой не должен уничтожить всю запись.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Для самостоятельного видеотеста (&lt;code&gt;AssessmentRecorder&lt;/code&gt;) компоновка не нужна: говорит один человек, поэтому достаточно одного рекордера для объединенного потока. Дополнительно &lt;code&gt;AssessmentRecorder&lt;/code&gt; дает &lt;strong&gt;барьер остановки на основе промиса&lt;/strong&gt;: &lt;code&gt;stopRecording()&lt;/code&gt; возвращает объект &lt;code&gt;Promise&lt;/code&gt;, который завершается только после того, как последний &lt;code&gt;Blob&lt;/code&gt; прошел через &lt;code&gt;ondataavailable&lt;/code&gt;. Благодаря этому код интерфейса, который хочет заменить устройство, точно знает, что поток уже можно безопасно разбирать.&lt;/p&gt;

&lt;p&gt;Оба рекордера учитывают обходное решение для Safari с &lt;code&gt;timeSlice&lt;/code&gt; из §8.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Тестирование в реальном мире
&lt;/h2&gt;

&lt;p&gt;Юнит-тесты ловят баги протокола, но ни один юнит-тест не скажет, что coturn во Франкфурте на этой неделе недоступен из сети конкретного российского провайдера. Поэтому мы сделали клиентскую диагностическую проверку.&lt;/p&gt;

&lt;p&gt;Для каждого сервера она создает одноразовый &lt;code&gt;RTCPeerConnection&lt;/code&gt;, указывает ему ровно один STUN- или TURN-сервер, открывает канал данных (data channel), чтобы принудительно запустить сбор кандидатов, и ждет результата ICE-процедуры. Возможные результаты:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Рабочий STUN-сервер должен дать кандидата типа &lt;code&gt;srflx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Рабочий TURN-сервер должен дать кандидата типа &lt;code&gt;relay&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Все остальное — тайм-аут через 10 секунд или ошибка соединения — означает, что этот сервер недоступен &lt;em&gt;для этого клиента&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Результат приводится к простой форме &lt;code&gt;{ url, type, status, candidates[], duration }&lt;/code&gt; и показывается на внутренней диагностической странице. Когда пользователь пишет «звонок не подключается», он может нажать одну кнопку и сразу увидеть, какие серверы доступны из его сети. Это сократило отладку с многодневной переписки до одного скриншота.&lt;/p&gt;

&lt;p&gt;В сценарии интервью поверх серверных проверок есть еще слой контроля нарушений (&lt;strong&gt;anti-cheating&lt;/strong&gt;), который отмечает переключения вкладок во время интервью. Это не совсем WebRTC-задача, но механизм работает через тот же сокетный канал, а пропавший сигнал о переключении вкладки бывает полезным косвенным признаком проблемы с соединением.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Что мы вынесли
&lt;/h2&gt;

&lt;p&gt;Коротко и без особого порядка:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Не доверяйте ни одной отдельной машине состояний WebRTC.&lt;/strong&gt; Перепроверяйте ее по изменениям RTP-счетчиков через &lt;code&gt;getStats()&lt;/code&gt;. &lt;code&gt;'connected'&lt;/code&gt; — это заявление, а не факт.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Буферизуйте ICE-кандидаты.&lt;/strong&gt; На медленном канале они действительно приходят раньше, чем завершается &lt;code&gt;setRemoteDescription&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Восстанавливайте соединение постепенно.&lt;/strong&gt; Пульсовая проверка → ICE restart → полный перезапуск → ожидание восстановления сети. Слишком ранний полный перезапуск хуже, чем еще несколько секунд работы в деградировавшем состоянии.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Восстановлением должна управлять одна сторона.&lt;/strong&gt; Перезапуск запускает инициатор звонка, а принимающая сторона обрабатывает полученный сигнал. Если перезапуск запускают оба участника, начинаются конфликты при согласовании SDP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Поднимайте собственный coturn на :443 с TLS.&lt;/strong&gt; Бесплатные STUN-серверы годятся; бесплатных TURN-серверов либо нет, либо они не масштабируются, либо находятся не там, где нужно.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Размещайте TURN в нескольких юрисдикциях&lt;/strong&gt;, если ваши пользователи находятся за национальными фильтрами. Российский случай сделал это для нас обязательным, но та же логика работает для корпоративного DPI и строгих мобильных операторов где угодно. Конфигурация на уровне клиента позволяет выбрать подходящий TURN-пул без нового развертывания.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Используйте &lt;code&gt;replaceTrack&lt;/code&gt; для замены устройств.&lt;/strong&gt; Пересогласовывать весь &lt;code&gt;RTCPeerConnection&lt;/code&gt; на каждый клик «сменить микрофон» медленно и ненадежно.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Разделяйте выбор пользователя и текущее рабочее состояние&lt;/strong&gt;, когда появляются виртуальные фоны или другие синтетические треки. Это избавляет от трудноуловимых ошибок в настройках устройств.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari требует &lt;code&gt;timeSlice&lt;/code&gt; для &lt;code&gt;MediaRecorder&lt;/code&gt;.&lt;/strong&gt; Всегда. И после каждого stop/start его нужно передавать снова.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox требует повторного &lt;code&gt;enumerateDevices()&lt;/code&gt; после выдачи разрешения.&lt;/strong&gt; Иначе список устройств будет заполнен пустыми подписями.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Сделайте собственную проверку доступности серверов.&lt;/strong&gt; Это небольшая задача, но она позволяет диагностировать проблемы в пользовательской сети за 30 секунд.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Видеозвонки Recruiter.AI продолжают развиваться: впереди настройка предпочтительных кодеков, одновременная передача нескольких видеопотоков разного качества (simulcast) и автоматическая адаптация качества к доступной пропускной способности канала. Но описанная здесь архитектура уже выдержала тысячи реальных интервью и самостоятельных видеотестов, в том числе в сетях, где WebRTC обычно ведет себя непредсказуемо. Если вы создаете похожую систему, начинать стоит не с интерфейса, а со схемы восстановления соединения и TURN-стратегии. От этих двух решений зависит все остальное.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webrtc</category>
      <category>javascript</category>
      <category>network</category>
    </item>
    <item>
      <title>Building a Resilient WebRTC Video Call</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Wed, 22 Apr 2026 23:49:54 +0000</pubDate>
      <link>https://dev.to/andreik/building-a-resilient-webrtc-video-call-12j4</link>
      <guid>https://dev.to/andreik/building-a-resilient-webrtc-video-call-12j4</guid>
      <description>&lt;p&gt;&lt;em&gt;How we designed, built, and battle‑tested the video calling engine that powers video interviews and video self‑assessment inside &lt;a href="https://recruiter-ai.andersenlab.com/" rel="noopener noreferrer"&gt;Recruiter.AI&lt;/a&gt; — and the long list of problems we had to beat along the way.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before we dive in, a quick note on the scope of this article. WebRTC is a huge topic, and there are entire books written about it. This is not a WebRTC tutorial. It’s a postmortem of the specific architecture and design decisions we made for Recruiter.AI’s video calling features, and the rationale behind them. &lt;/p&gt;

&lt;p&gt;You can find the video call code in the &lt;a href="https://github.com/gsbelarus/gdmn-meet" rel="noopener noreferrer"&gt;GDMN Meet repository&lt;/a&gt;, try it out on &lt;a href="https://meet.gdmn.app" rel="noopener noreferrer"&gt;meet.gdmn.app&lt;/a&gt;, or see it in action in the &lt;a href="https://recruiter-ai.andersenlab.com/" rel="noopener noreferrer"&gt;Recruiter.AI interview flow&lt;/a&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why this was built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://recruiter-ai.andersenlab.com/" rel="noopener noreferrer"&gt;Recruiter.AI&lt;/a&gt; from &lt;strong&gt;Andersen Lab&lt;/strong&gt; is a platform for AI‑assisted hiring. Two of its most visible features — &lt;strong&gt;video interviews&lt;/strong&gt; between a candidate and an interviewer, and &lt;strong&gt;video self‑assessment&lt;/strong&gt; where a candidate records answers to questions alone in front of the camera — depend on a live video stream that has to &lt;em&gt;just work&lt;/em&gt;. Not in the lab. In a candidate's bedroom, on a hotel Wi‑Fi, on a flaky mobile network, behind a corporate firewall, behind a national DPI filter.&lt;/p&gt;

&lt;p&gt;What we will cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the end‑to‑end architecture and why it looks the way it does;&lt;/li&gt;
&lt;li&gt;why we built our own thin Socket.IO signaling server;&lt;/li&gt;
&lt;li&gt;STUN, TURN, and the &lt;strong&gt;jurisdiction problem&lt;/strong&gt; (for example, the one that stops calls working inside Russian RKN‑filtered networks);&lt;/li&gt;
&lt;li&gt;Perfect Negotiation with buffered ICE candidates;&lt;/li&gt;
&lt;li&gt;the multi‑tier connection recovery needed to survive bad links;&lt;/li&gt;
&lt;li&gt;how we detect &lt;em&gt;frozen&lt;/em&gt; media that every browser still calls "connected";&lt;/li&gt;
&lt;li&gt;Chrome vs. Firefox vs. Safari — concrete workarounds;&lt;/li&gt;
&lt;li&gt;equipment checks, hot‑swap of cameras and microphones mid‑call;&lt;/li&gt;
&lt;li&gt;the recording pipeline (canvas compositing + chunked upload + a Safari fix that took two days to find);&lt;/li&gt;
&lt;li&gt;the tooling we built to test it all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. The big picture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzolji7co58gw26t2ezlm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzolji7co58gw26t2ezlm.png" alt="Architecture overview — peers, Socket.IO signaling, STUN, TURN over TLS in multiple jurisdictions, and the chunked‑upload recording sink" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At a high level there are four planes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Media plane (P2P).&lt;/strong&gt; Two &lt;code&gt;RTCPeerConnection&lt;/code&gt;s exchange encrypted DTLS‑SRTP traffic, ideally peer‑to-peer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signaling plane.&lt;/strong&gt; A thin Socket.IO server — provided by the application server — that forwards typed messages between participants in a room and keeps just enough presence state to reject duplicate joins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ICE plane.&lt;/strong&gt; STUN servers (Google, Cloudflare, and our own coturn server) for NAT traversal, plus a set of &lt;strong&gt;TURN over TLS&lt;/strong&gt; relays on port 443 that act as media hairpins when direct P2P can't be established — which is more often than you'd think.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recording sink.&lt;/strong&gt; A chunked upload API that accepts 30‑second media blobs produced by &lt;code&gt;CallRecorder&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the client, responsibilities are split across two cooperating hooks wired together by a thin integration layer: one owns the peer connection, signaling and recovery; the other owns cameras, microphones, device hot‑plug handling and the local stream. Keeping those responsibilities separate was one of the first good decisions — swapping devices mid‑call is already tricky enough without also owning signaling state.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The signaling server: thin, recoverable, ours
&lt;/h2&gt;

&lt;p&gt;For signaling we run a Socket.IO endpoint as part of the application server. Its job is narrow and strictly defined:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dumb relay.&lt;/strong&gt; The server forwards messages and nothing else. No call state lives on the server, which means the signaling protocol can evolve without server‑side changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self‑recovering&lt;/strong&gt; at the socket layer so a candidate who blips off Wi‑Fi for ten seconds does not have to refresh the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presence‑aware, minimally.&lt;/strong&gt; It tracks room membership just enough to reject duplicate joins from the same participant (more on that below).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second point is the reason we picked Socket.IO over a raw WebSocket: Socket.IO ships &lt;code&gt;connectionStateRecovery&lt;/code&gt;, which buffers events for a configurable window and resumes the session if the client reconnects in time. The window is set to &lt;strong&gt;five minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The protocol itself is tiny: nine message types — JOIN/LEAVE, ENUM, CALLING, ANSWER_CALL, SIGNAL (which wraps both SDP and ICE), SOCKET_PING/PONG, RESTART_CALL, END_CALL, MAKE_CALL. Each message carries &lt;code&gt;fromId&lt;/code&gt;/&lt;code&gt;toId&lt;/code&gt; so the server can stay stateless about who is talking to whom.&lt;/p&gt;

&lt;p&gt;Sequence of a successful call setup:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv51h7k122m2lnvhwhy8u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv51h7k122m2lnvhwhy8u.png" alt="Signaling sequence — JOIN, CALLING, offer/answer, ICE trickle, heartbeat" width="800" height="1471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two small but important details in the connection bootstrap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every connection attempt sends a fresh &lt;code&gt;instanceId&lt;/code&gt; (UUID v4) alongside the &lt;code&gt;participantId&lt;/code&gt;. If a second tab joins with the same &lt;code&gt;participantId&lt;/code&gt;, the server replies with &lt;code&gt;already_in_room&lt;/code&gt; and the second tab backs off. This is how we detect and reject &lt;strong&gt;duplicate entries&lt;/strong&gt; (the most common cause of "I see two of myself in the grid" reports from testers).&lt;/li&gt;
&lt;li&gt;On tab unload, the client fires a LEAVE message through &lt;code&gt;navigator.sendBeacon()&lt;/code&gt;. &lt;code&gt;window.onbeforeunload&lt;/code&gt; is not reliable on mobile browsers; &lt;code&gt;sendBeacon&lt;/code&gt; is.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. STUN, TURN, and the jurisdiction problem
&lt;/h2&gt;

&lt;p&gt;Getting ICE candidates is the easy part — point &lt;code&gt;RTCPeerConnection&lt;/code&gt; at a few public STUN servers and you're done. The default config:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;stun:stun.l.google.com:19302&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stun:stun.cloudflare.com:3478&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;our own server, for redundancy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hard part is TURN. A non‑trivial fraction of users — corporate networks, symmetric NATs, mobile carriers with carrier‑grade NAT, and, crucially for Recruiter.AI, &lt;strong&gt;users inside nationally filtered networks&lt;/strong&gt; — cannot establish direct peer connections. They need a relay.&lt;/p&gt;

&lt;p&gt;So we stood up our own &lt;strong&gt;coturn&lt;/strong&gt; instance and crucially exposed TURN on port &lt;strong&gt;443&lt;/strong&gt;. The ICE server URL looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;turns&lt;/span&gt;:&lt;span class="n"&gt;coturn&lt;/span&gt;.&lt;span class="n"&gt;our_server&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;:&lt;span class="m"&gt;443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 443 matters. A lot of corporate firewalls and national DPI filters let TCP/443 TLS through because blocking it would break HTTPS. A TURN server listening on that port, inside a proper TLS tunnel, is indistinguishable from a normal HTTPS session to most DPI. That is why we use &lt;strong&gt;TURN over TLS on :443&lt;/strong&gt; rather than the default UDP 3478 or TCP 5349 transports.&lt;/p&gt;

&lt;p&gt;But there is a further twist, particular to running Recruiter.AI for users inside restrictive countries.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Russian filter problem
&lt;/h3&gt;

&lt;p&gt;Russia's national traffic filtering infrastructure (the RKN system, including the TSPU boxes deployed at ISP peering points) does not only block &lt;em&gt;well‑known&lt;/em&gt; addresses. It also drops connections to endpoints whose IPs or SNI hostnames have been added to the filter lists, and it has become increasingly aggressive about throttling or blocking traffic that &lt;em&gt;looks&lt;/em&gt; like WebRTC — long‑lived UDP flows, or STUN packet patterns over TCP. A TURN server that works flawlessly from Amsterdam can suddenly be unreachable from Moscow overnight, because &lt;em&gt;somebody else's&lt;/em&gt; traffic from the same IP range poisoned the filter.&lt;/p&gt;

&lt;p&gt;The practical consequence is that &lt;strong&gt;a single TURN deployment — even a good one — is not enough&lt;/strong&gt;. What we ended up doing is placing TURN servers in different jurisdictions so that users from any particular region always have a reachable relay whose IP/hostname is clean for &lt;em&gt;their&lt;/em&gt; network path. The WebRTC config is resolved per organization at runtime through an internal service registry, with an env‑based default as a fallback. That lets us route tenants to the TURN pool whose jurisdiction and IP hygiene actually work for them, without requiring a redeploy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F326l24vz1wawv0qxishl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F326l24vz1wawv0qxishl.png" alt="TURN in a reachable jurisdiction, relaying media on :443 for a peer behind a national DPI filter" width="800" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Building the config
&lt;/h3&gt;

&lt;p&gt;The config builder is where env JSON becomes &lt;code&gt;RTCConfiguration&lt;/code&gt;. It's small but it's the place that gets wrong defaults wrong in exciting ways, so we kept it strict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getRTCConfiguration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;stun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;icePolicy&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;WebRTCConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;RTCConfiguration&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RTCConfiguration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;stun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;iceTransportPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;icePolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;icePolicy&lt;/code&gt; is configurable: default is &lt;code&gt;'all'&lt;/code&gt;, but it can be flipped to &lt;code&gt;'relay'&lt;/code&gt; at config level — useful for debugging, or for tenants where a uniform TURN path is required for jurisdictional reasons — without touching the call code.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Perfect Negotiation with buffered ICE
&lt;/h2&gt;

&lt;p&gt;Two peers can try to create an offer at the same time — classic WebRTC glare. The W3C &lt;a href="https://w3c.github.io/webrtc-pc/#perfect-negotiation-example" rel="noopener noreferrer"&gt;Perfect Negotiation pattern&lt;/a&gt; assigns one side as "polite" and lets it roll back its own local description if an incoming offer collides. We implement it with the usual &lt;code&gt;makingOffer&lt;/code&gt;, &lt;code&gt;ignoreOffer&lt;/code&gt; and &lt;code&gt;polite&lt;/code&gt; flags. The callee is the polite peer.&lt;/p&gt;

&lt;p&gt;There is one detail that bites: ICE candidates can arrive &lt;em&gt;before&lt;/em&gt; the remote description is set, especially on a slow network where SDP takes a while to traverse but ICE candidates start trickling immediately. Calling &lt;code&gt;addIceCandidate&lt;/code&gt; before &lt;code&gt;setRemoteDescription&lt;/code&gt; throws in modern browsers. So we &lt;strong&gt;buffer&lt;/strong&gt; ICE candidates per peer and flush them after &lt;code&gt;setRemoteDescription&lt;/code&gt; resolves. The buffer is not large in practice — usually a handful of candidates — but without it, the call silently fails to connect on bad links.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Recovery, step by step
&lt;/h2&gt;

&lt;p&gt;This was the part that took the most iteration. WebRTC gives you &lt;code&gt;connectionState&lt;/code&gt;, &lt;code&gt;iceConnectionState&lt;/code&gt;, &lt;code&gt;iceGatheringState&lt;/code&gt;, &lt;code&gt;signalingState&lt;/code&gt; — and at least one of them lies to you at any given moment. A &lt;code&gt;connectionState === 'connected'&lt;/code&gt; peer can still be sending zero bytes because the path got silently blackholed. A peer in &lt;code&gt;iceConnectionState === 'disconnected'&lt;/code&gt; can recover on its own in a second if you just wait.&lt;/p&gt;

&lt;p&gt;So we layered several signals together and built an escalating recovery ladder with five thresholds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heartbeatDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heartbeatThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;heartbeatDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 5 s&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statsDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;// full restart&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iceRestartDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;//  9 s — ICE restart&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoWarmUpDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waitingForInternetDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// give up&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ladder:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzhejr9gctxnlh0hxcluw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzhejr9gctxnlh0hxcluw.png" alt="Recovery state machine — Healthy → PreProblem → Degraded → IceRestart → FullRestart → WaitInternet → Ended" width="800" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Translated into words:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every 2.5 s&lt;/strong&gt; each side sends a &lt;code&gt;SOCKET_PING&lt;/code&gt;; the counterpart replies with &lt;code&gt;SOCKET_PONG&lt;/code&gt;. Two missed in a row (5 s) means the socket path is likely dead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every ~5 s&lt;/strong&gt; a stats poll runs &lt;code&gt;pc.getStats()&lt;/code&gt; and diffs RTP byte counters. If neither inbound nor outbound bytes moved, media is frozen regardless of what the state machine says.&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;first&lt;/strong&gt; tick where something looks wrong (&lt;code&gt;connectionState === 'failed'&lt;/code&gt;, ICE disconnected/closed, stream inactive, or no bytes moved), we stamp a &lt;code&gt;preProblemDiscovered&lt;/code&gt;. If the next tick still looks bad, it promotes to &lt;code&gt;problemDiscovered&lt;/code&gt;. This debounce by itself removed the majority of false alarms in testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After 9 s&lt;/strong&gt; of real trouble, if the socket is still alive and we are the caller, &lt;code&gt;pc.restartIce()&lt;/code&gt; is called. That triggers ICE renegotiation without tearing down the peer connection — cheap and fast when it works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After 18 s&lt;/strong&gt;, we escalate to a full &lt;code&gt;restartCall()&lt;/code&gt;: close the &lt;code&gt;RTCPeerConnection&lt;/code&gt;, send &lt;code&gt;RESTART_CALL&lt;/code&gt; over the socket so the other side does the same, and rebuild from scratch. Expensive but reliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the socket itself is down&lt;/strong&gt; or &lt;code&gt;navigator.onLine === false&lt;/code&gt;, we wait instead of restarting — there is nothing to restart &lt;em&gt;over&lt;/em&gt;. The wait runs up to 60 s; only then does the UI say "Connection lost. Please check internet connection."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The branching logic is dense and it took a lot of trial runs on a throttled network to get right. The key block, from the stats‑tick loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onLine&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;missedHeartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socketPingReceived&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;waitingForInternetDelay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;endCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Connection lost. Please check internet connection.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;endCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;updateParticipant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;healthCheck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;caller&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;restartCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// caller must manage the call, but if it avoids its responsibilities...&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;waitingForInternetDelay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;recoveryDelay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;endCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;updateParticipant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;healthCheck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two subtleties worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only the caller initiates restart.&lt;/strong&gt; If both sides raced to restart, they'd endlessly step on each other's SDP. The callee passively follows. But if the caller &lt;em&gt;abdicates&lt;/em&gt; — for example, its tab was suspended — the callee eventually gives up on its own after a longer timeout (&lt;code&gt;waitingForInternetDelay + recoveryDelay = 78 s&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The "elapsed since problem" time is computed against &lt;code&gt;Math.max(problemDiscovered, lastSocketConnected, socketPingRestored, lastIceStateChange)&lt;/code&gt;. If the socket flaps or the ICE state briefly recovers, the recovery clock resets. Without that, a call that bounced through a short outage would get killed even after it stabilized.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Detecting frozen media
&lt;/h2&gt;

&lt;p&gt;Possibly the most satisfying bit of code in the whole project is also the smallest. We keep a running delta of RTP bytes from &lt;code&gt;pc.getStats()&lt;/code&gt;. If both sent and received deltas are zero over a full stats window, the connection is dead — no matter what &lt;code&gt;iceConnectionState&lt;/code&gt; claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDataTransmitted&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Participant&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statInterval&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesTime&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevReceived&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevTime&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesSent&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevSent&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deltaTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesTime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deltaReceived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevReceived&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deltaSent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesSent&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesPrevSent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// only judge if there were enough time to transmit some data&lt;/span&gt;
    &lt;span class="c1"&gt;// Connection is alive if EITHER data is being received OR sent (not both required)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;deltaTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;statsDelay&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deltaReceived&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;deltaSent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;deltaReceived &amp;gt; 0 || deltaSent &amp;gt; 0&lt;/code&gt; (rather than &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;) was a conscious call. One side can legitimately be muted; demanding &lt;em&gt;both&lt;/em&gt; byte streams to flow would produce spurious "dead call" alarms when a participant stops talking. As long as &lt;em&gt;something&lt;/em&gt; is moving, the link is alive.&lt;/p&gt;

&lt;p&gt;There is also a tiny grace period for the &lt;em&gt;initial&lt;/em&gt; media flow after a call is established (&lt;code&gt;videoWarmUpDelay = 8_000&lt;/code&gt;). Some browsers — Safari in particular — take a noticeable time to push the first video frame even after &lt;code&gt;connectionState === 'connected'&lt;/code&gt;. Complaining about "no video" before eight seconds produces a false positive.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Browser wars: Chrome, Firefox, Safari
&lt;/h2&gt;

&lt;p&gt;Nothing in this project ate more time than cross‑browser quirks. The concrete ones worth writing down:&lt;/p&gt;

&lt;h3&gt;
  
  
  Safari: &lt;code&gt;MediaRecorder&lt;/code&gt; is silent until you ask
&lt;/h3&gt;

&lt;p&gt;In Chrome and Firefox, &lt;code&gt;MediaRecorder.start()&lt;/code&gt; with no argument produces one big blob on &lt;code&gt;stop()&lt;/code&gt;, or periodic blobs if you give it a &lt;code&gt;timeSlice&lt;/code&gt; in ms. In Safari, &lt;code&gt;start()&lt;/code&gt; without a &lt;code&gt;timeSlice&lt;/code&gt; can silently never emit anything. The recorder &lt;em&gt;thinks&lt;/em&gt; it is recording — no errors, no rejected promises, just an eternal nothing.&lt;/p&gt;

&lt;p&gt;This one cost us two days in the self‑assessment flow. The fix is a browser‑gated &lt;code&gt;useTimeSlice&lt;/code&gt; flag in both recorder classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useTimeSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browserName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Safari&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;…&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useTimeSlice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIME_SLICE&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIME_SLICE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1000 ms&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a second layer: when the 30‑second chunk boundary fires and the recorder stops and restarts, Safari only starts producing data again if &lt;code&gt;timeSlice&lt;/code&gt; is passed to &lt;code&gt;start()&lt;/code&gt; &lt;em&gt;every&lt;/em&gt; time. So the same flag governs the restart path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firefox: device labels are empty until you ask
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;navigator.mediaDevices.enumerateDevices()&lt;/code&gt; returns &lt;code&gt;label: ""&lt;/code&gt; for every device until &lt;code&gt;getUserMedia()&lt;/code&gt; has been granted at least once. Chrome fills labels pre‑permission if the site has been granted before; Firefox does not. So we enumerate once, request permission, then &lt;strong&gt;re‑enumerate&lt;/strong&gt; and use the new labels to populate the device pickers.&lt;/p&gt;

&lt;h3&gt;
  
  
  MIME negotiation
&lt;/h3&gt;

&lt;p&gt;Chrome and Firefox prefer WebM (VP8/VP9 video, Opus audio). Safari only reliably encodes MP4 with AVC and AAC. We run a support check at load time and pick the best supported combination, falling back to &lt;code&gt;'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'&lt;/code&gt; for Safari. That decision ripples into the uploaded file extension (&lt;code&gt;.webm&lt;/code&gt; vs &lt;code&gt;.mp4&lt;/code&gt;), which the backend uses to route to the right post‑processing pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  iOS
&lt;/h3&gt;

&lt;p&gt;On iOS the video constraints always include &lt;code&gt;facingMode: 'user'&lt;/code&gt; so the front camera comes up by default; &lt;code&gt;deviceId: { exact: … }&lt;/code&gt; on iOS sometimes picks an unexpected lens. A small but persistent source of bug reports until standardising on &lt;code&gt;facingMode&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Hardware: check, swap, recover
&lt;/h2&gt;

&lt;p&gt;Because interviews and self‑assessments are &lt;em&gt;recorded&lt;/em&gt; evaluations of real people, getting the hardware right matters. Three things help here.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.1 The pre‑call sound check
&lt;/h3&gt;

&lt;p&gt;Before the user joins a call, a pre‑call sound‑check modal paired with a live volume meter walks them through a microphone test. It spins up an &lt;code&gt;AudioContext&lt;/code&gt; + &lt;code&gt;AnalyserNode&lt;/code&gt; on a dedicated &lt;code&gt;getUserMedia({ audio: { deviceId: { exact } } })&lt;/code&gt; stream, captures frequency data, tracks a peak dB value, and compares against a −35 dB threshold. If the user's microphone peaks below that, we warn them before the call starts rather than after the interviewer is already waiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.2 Hot‑swap during a call
&lt;/h3&gt;

&lt;p&gt;If a candidate plugs in USB headphones mid‑call, the call must not die. That has two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The media‑devices hook listens to &lt;code&gt;navigator.mediaDevices.ondevicechange&lt;/code&gt; with a 1‑second debounce (the event fires several times in quick succession during a plug).&lt;/li&gt;
&lt;li&gt;When the user picks a new device, the peer connection is not renegotiated — the track is swapped underneath it using &lt;code&gt;RTCRtpSender.replaceTrack()&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// useVideoCall switchTrack (wired from useMediaDevices)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;participantsRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in-call&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSenders&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;newTrack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTrack&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no renegotiation&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;localStreamRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;replaceTrack&lt;/code&gt; is the right tool: no new offer/answer, the remote side sees an uninterrupted track, and the recorder (if running) keeps going.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.3 Recovery when a device vanishes
&lt;/h3&gt;

&lt;p&gt;If the active camera or microphone &lt;em&gt;disappears&lt;/em&gt; — USB unplug, OS‑level permission revoked, the browser decides to put a different device in your face — a two‑stage recovery kicks in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Soft recovery.&lt;/strong&gt; The &lt;code&gt;track.onended&lt;/code&gt; handler marks the device type as "needs replacement". The &lt;code&gt;devicechange&lt;/code&gt; debounce fires, we re‑enumerate, pick the first available device of that kind, and call &lt;code&gt;switchDevices&lt;/code&gt; through the same &lt;code&gt;replaceTrack&lt;/code&gt; path as a normal user swap. To the remote peer, nothing happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard recovery.&lt;/strong&gt; If no replacement exists (all cameras unplugged; permissions revoked), the local stream is stopped cleanly, a recovery context is preserved with the old/new device IDs, and a typed error (&lt;code&gt;isNotAllowedError&lt;/code&gt;, &lt;code&gt;isNotFoundError&lt;/code&gt;, &lt;code&gt;isNotReadableError&lt;/code&gt;, &lt;code&gt;isOverconstrainedError&lt;/code&gt;) surfaces up to a dedicated media‑error UI. It uses the error flags to show the right remediation — "Grant camera permission", "Plug in a camera", "Close the other app using your camera", etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One extra wrinkle: background removal (the virtual‑background feature) generates its own &lt;strong&gt;synthetic track&lt;/strong&gt; with a fake &lt;code&gt;deviceId&lt;/code&gt;. Naively writing that synthetic ID back to the selected‑camera state would leave the user with a UUID that doesn't map to any real device the next time they open the settings panel. The fix was to separate &lt;strong&gt;user intent&lt;/strong&gt; (&lt;code&gt;selectedCameraId&lt;/code&gt;) from &lt;strong&gt;runtime state&lt;/strong&gt; (&lt;code&gt;currentCamera&lt;/code&gt;): when background is on, we remember the originally selected camera ID rather than the synthetic one.&lt;/p&gt;

&lt;p&gt;All device operations are serialized through a &lt;code&gt;Semaphore&lt;/code&gt; so rapid user clicks on the device dropdown can't produce overlapping &lt;code&gt;getUserMedia&lt;/code&gt; calls and orphaned tracks.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. The recording pipeline
&lt;/h2&gt;

&lt;p&gt;Recording had its own set of challenges because the video call is &lt;em&gt;multi‑party&lt;/em&gt; (interviewer + candidate, sometimes more) but the output needs to be a single watchable asset.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkf943p6l7531jlq2i6rc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkf943p6l7531jlq2i6rc.png" alt="Recording pipeline — canvas compositing + per‑participant audio + 30 s chunked upload" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a call (&lt;code&gt;CallRecorder&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every participant's &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element is drawn into a &lt;strong&gt;2×2 canvas grid&lt;/strong&gt; every animation frame. &lt;code&gt;canvas.captureStream()&lt;/code&gt; turns that into a &lt;code&gt;MediaStream&lt;/code&gt; with a single composite video track.&lt;/li&gt;
&lt;li&gt;That composite stream feeds one &lt;code&gt;MediaRecorder&lt;/code&gt; for video.&lt;/li&gt;
&lt;li&gt;In parallel, each participant's audio track feeds &lt;strong&gt;its own&lt;/strong&gt; &lt;code&gt;MediaRecorder&lt;/code&gt;. Keeping audio separate means per‑speaker transcription downstream is straightforward (no need to separate mixed audio), and it sidesteps the issue of WebRTC audio tracks being difficult to mix losslessly on the fly.&lt;/li&gt;
&lt;li&gt;Every 30 s the recorders rotate: &lt;code&gt;stop()&lt;/code&gt; emits the last blob via &lt;code&gt;ondataavailable&lt;/code&gt;, then &lt;code&gt;start(TIME_SLICE)&lt;/code&gt; begins the next chunk. Each blob is uploaded immediately, tagged with &lt;code&gt;interviewId&lt;/code&gt;, &lt;code&gt;participantId&lt;/code&gt;, &lt;code&gt;timestamp&lt;/code&gt;, and the media type extension. If the upload fails, the chunk is retried independently — no single network blip can lose the whole recording.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a self‑assessment (&lt;code&gt;AssessmentRecorder&lt;/code&gt;), there is no compositing — it's a single speaker — so a single recorder for the combined stream is enough. What &lt;code&gt;AssessmentRecorder&lt;/code&gt; adds is a &lt;strong&gt;promise‑based stop barrier&lt;/strong&gt;: &lt;code&gt;stopRecording()&lt;/code&gt; returns a promise that only resolves once the last blob has fired through &lt;code&gt;ondataavailable&lt;/code&gt;, so UI code that wants to swap devices knows it's safe to tear down the stream.&lt;/p&gt;

&lt;p&gt;Both recorders honour the Safari &lt;code&gt;timeSlice&lt;/code&gt; workaround from §8.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Testing in the wild
&lt;/h2&gt;

&lt;p&gt;Unit tests can catch protocol bugs, but no unit test will tell you that coturn in Frankfurt is unreachable from a specific Russian ISP this week. So we shipped a client‑side probe.&lt;/p&gt;

&lt;p&gt;Per‑server, it spins up a disposable &lt;code&gt;RTCPeerConnection&lt;/code&gt;, points it at just that one STUN or TURN server, creates a data channel to force candidate gathering, and waits for the ICE process to complete or fail. The result is one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A working STUN server should produce a &lt;code&gt;srflx&lt;/code&gt; candidate.&lt;/li&gt;
&lt;li&gt;A working TURN server should produce a &lt;code&gt;relay&lt;/code&gt; candidate.&lt;/li&gt;
&lt;li&gt;Anything else — timeout after 10 s, connection error — means that server is broken &lt;em&gt;for this client&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a simple &lt;code&gt;{ url, type, status, candidates[], duration }&lt;/code&gt; shape, surfaced in an internal diagnostics page. When a user reports "the call won't connect", they can click one button and instantly see which servers are reachable from their network. This has cut our debug cycle from "multi‑day email thread" to "one screenshot".&lt;/p&gt;

&lt;p&gt;On top of the server probes, the interview flow also has an &lt;strong&gt;anti‑cheating&lt;/strong&gt; layer that flags tab switches during an interview — not a WebRTC issue per se, but it rides on the same socket channel, and a missing tab‑switch signal is a useful side‑channel indicator of a broken connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Lessons learned
&lt;/h2&gt;

&lt;p&gt;Condensed, in no particular order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust any single WebRTC state machine.&lt;/strong&gt; Cross‑check with RTP byte deltas over &lt;code&gt;getStats()&lt;/code&gt;. &lt;code&gt;'connected'&lt;/code&gt; is a claim, not a fact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffer ICE candidates.&lt;/strong&gt; On a slow link they &lt;em&gt;will&lt;/em&gt; arrive before &lt;code&gt;setRemoteDescription&lt;/code&gt; resolves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalate recovery gradually.&lt;/strong&gt; Heartbeat → ICE restart → full restart → wait for internet. Full restart too eagerly is worse than letting the call limp for 10 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only one side drives recovery.&lt;/strong&gt; Caller restarts, callee follows. Both restarting is chaos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run your own coturn on :443 with TLS.&lt;/strong&gt; Free STUN servers are fine; free TURN servers either don't exist, don't scale, or are in the wrong country.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Place TURN in multiple jurisdictions&lt;/strong&gt; when your users live behind national filters. The Russian case made this non‑negotiable for us, but the same logic applies to corporate DPI and strict mobile carriers everywhere. Per‑tenant config lets you route the TURN pool to the client without a redeploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;replaceTrack&lt;/code&gt; for device swaps.&lt;/strong&gt; Renegotiating the whole peer connection on every "switch microphone" click is slow and fragile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate user intent from runtime state&lt;/strong&gt; when virtual backgrounds or other synthetic tracks enter the picture. Future‑you will thank you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari's &lt;code&gt;MediaRecorder&lt;/code&gt; needs &lt;code&gt;timeSlice&lt;/code&gt;.&lt;/strong&gt; Always. And pass it again after every stop/start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox needs a device re‑enumeration after permission grant.&lt;/strong&gt; Otherwise your device picker is full of blank labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship your own server‑reachability probe.&lt;/strong&gt; The cost is a Saturday; the payoff is being able to diagnose a user's broken network in 30 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recruiter.AI's video call still evolves — codec preferences, simulcast, and bandwidth‑adaptive quality are on the roadmap — but the bones described here have held up through thousands of real interviews and self‑assessments, including plenty conducted on networks that would make any WebRTC engineer wince. For anyone building something similar: start with the recovery ladder and the TURN story, not with the UI. Everything else is downstream of getting those two right.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>messenger</category>
      <category>network</category>
      <category>recruiting</category>
    </item>
    <item>
      <title>What do you do while waiting for the code?</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Tue, 21 Apr 2026 00:15:37 +0000</pubDate>
      <link>https://dev.to/andreik/what-do-you-do-while-waiting-for-the-code-ca8</link>
      <guid>https://dev.to/andreik/what-do-you-do-while-waiting-for-the-code-ca8</guid>
      <description>&lt;p&gt;Waiting for the LLM to produce the next patch is painful. The last time I spent so much time just waiting was in the late 80s, when the MK-61 programmable calculator took me to the Moon and back. It blinked while crunching the digits (the same way older people murmur numbers when doing addition or subtraction in their heads), running a 105-step program in over a minute.&lt;/p&gt;

&lt;p&gt;Yes, there were games for programmable calculators. Plenty of them, actually. There was even a section in a popular Soviet technical magazine where new games were published, discussed, and sometimes accompanied by sci-fi stories based on them.&lt;/p&gt;

&lt;p&gt;What do other people do in those moments of waiting now? Just watch the streaming progress, like the heroes in &lt;em&gt;The Matrix&lt;/em&gt; watching green falling hieroglyphs? Open another IDE in parallel, trying to match the heroics of Alexei Stakhanov? (They weren’t actually heroic, we know.) Keep an eye on the hopeless Phoenix Suns losing to the Oklahoma City Thunder? Learn a foreign language? Enjoy cute Korean cheerleaders on YouTube? Or just wonder what else you could do once it’s finally done with the programming?&lt;/p&gt;

&lt;p&gt;Just curious.&lt;/p&gt;

</description>
      <category>vibecoding</category>
      <category>programming</category>
    </item>
    <item>
      <title>A Correspondence Match</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Thu, 22 Jan 2026 21:52:42 +0000</pubDate>
      <link>https://dev.to/andreik/a-correspondence-match-2op1</link>
      <guid>https://dev.to/andreik/a-correspondence-match-2op1</guid>
      <description>&lt;p&gt;In the old Polish comedy "Mistrz zawsze traci" from 1976, a crafty French pharmacist arranges a correspondence match between two grandmasters who have never met each other.&lt;/p&gt;

&lt;p&gt;For the past couple of days, I’ve been feeling like that very swindler. I explained to Copilot what needed to be done, waited for it to write the code, then wrapped it all up into a pull request. Then I waited for Cursor BugBot to do the review, copied its recommendations, and forwarded them back to Copilot for fixes. And so it goes, round and round.&lt;/p&gt;

&lt;p&gt;Alas, exposure awaits us all sooner or later.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>vibecoding</category>
      <category>jokes</category>
    </item>
    <item>
      <title>How to connect the Next.js MCP server to VS Code Copilot Chat</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sat, 22 Nov 2025 21:35:49 +0000</pubDate>
      <link>https://dev.to/andreik/how-to-connect-the-nextjs-mcp-server-to-vs-code-copilot-chat-3pii</link>
      <guid>https://dev.to/andreik/how-to-connect-the-nextjs-mcp-server-to-vs-code-copilot-chat-3pii</guid>
      <description>&lt;p&gt;The MCP server functionality is built in starting from Next.js 16 and doesn't require any additional configuration in the settings files. The path to the MCP server API is &lt;code&gt;/_next/mcp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Assuming your dev server is running on the port number 5002, do the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the &lt;code&gt;.vscode&lt;/code&gt; folder in the root of your project, create a &lt;code&gt;mcp.json&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Put the following content inside it:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nextjs-mcp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:5002/_next/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Save the file.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run your project's dev server.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open Copilot Chat.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Ask something specific to Next.js, for example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;nextjs devtools, are there any build errors?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If prompted, allow running the MCP server and executing actions. &lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In our case, the answer was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No Next.js devtools build errors are currently reported. Let me know if you want me to keep monitoring or run a build/test explicitly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now let's ask what the MCP server could do for us:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;nextjs devtools, tell me what you are able to do?&lt;/p&gt;

&lt;p&gt;I’m connected to the Next.js devtools MCP server, so I can pull diagnostics like build/runtime errors, fetch the dev-server logs, list app/pages routes, and inspect server actions. If you need anything along those lines—checking status, seeing routes, digging into errors—just let me know which detail you want.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Enjoy, and happy vibe coding!&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>mcp</category>
      <category>githubcopilot</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>Unit Testing React Hooks with NextJS Back-End: Challenges and Solutions</title>
      <dc:creator>Andrej Kirejeŭ</dc:creator>
      <pubDate>Sat, 31 May 2025 10:30:11 +0000</pubDate>
      <link>https://dev.to/andreik/unit-testing-react-hooks-with-nextjs-back-end-challenges-and-solutions-4pk5</link>
      <guid>https://dev.to/andreik/unit-testing-react-hooks-with-nextjs-back-end-challenges-and-solutions-4pk5</guid>
      <description>&lt;p&gt;Implementing unit tests for React hooks that interact with a back-end can be challenging, especially when the back-end server is built with NextJS. As of Spring 2025, Copilot does not provide assistance for this task, so developers must rely on their own expertise. We hope that the insights shared in this document will save others in similar situations from spending excessive time searching through resources and attempting to integrate various libraries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Initial state:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;A NextJS application where the NextJS server also is the application’s back-end server.&lt;/li&gt;
&lt;li&gt;A React hook to be tested that heavily interacts with the back-end server.&lt;/li&gt;
&lt;li&gt;Node test runner used as testing framework.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Custom server.ts
&lt;/h3&gt;

&lt;p&gt;In our case the &lt;code&gt;server.ts&lt;/code&gt; file is customized to accommodate specific back-end logic and provide global data structures shared among Next.js server pages and route handlers.&lt;/p&gt;

&lt;p&gt;In development mode, the server is run using the command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cross-env NODE_ENV=development tsx watch --conditions=typescript --tsconfig tsconfig.server.json ./server.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production mode, the server is built and then executed as a JavaScript script using bare Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cross-env NODE_ENV=production next build &amp;amp;&amp;amp; tsc --project tsconfig.server.json"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cross-env NODE_ENV=production node dist/server.js"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To test React hooks outside the browser environment, install the following libraries as development dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @testing-library/dom @testing-library/react global-jsdom jsdom &lt;span class="nt"&gt;-D&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Writing a unit test
&lt;/h3&gt;

&lt;p&gt;For unit testing, a separate instance of the server should be launched before the test begins. The React hook is then tested, and upon completion of the test, the server instance is shut down.&lt;/p&gt;

&lt;p&gt;Running node directly appears to be the main issue. Problems were encountered when running the server through &lt;code&gt;pnpm run&lt;/code&gt; or &lt;code&gt;tsx&lt;/code&gt;; the node test runner would hang at the end until &lt;code&gt;Ctrl-C&lt;/code&gt; was manually pressed in the terminal window.&lt;/p&gt;

&lt;p&gt;Here are functions to start and safely shut down the server as a child process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server setup&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChildProcess&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Start the backend server before tests
 */&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startServer&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Starting server...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;serverProcess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist/server.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;serverReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;serverReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server failed to start within timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server stdout:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Look for server ready indicator&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server is running on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;serverReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;serverReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="c1"&gt;// Small delay to ensure server is fully ready&lt;/span&gt;
            &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server stderr:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server process error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server process exited with code:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;serverReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Server exited with code &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Stop the backend server after tests
 */&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stopServer&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cleaning up test environment...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Stopping server...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Set up exit handler before killing&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exitHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server stopped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;serverProcess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exitHandler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Try graceful shutdown first&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sending SIGTERM to server...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Force kill after timeout&lt;/span&gt;
      &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;killed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Force killing server...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGKILL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="c1"&gt;// Give it a moment to clean up&lt;/span&gt;
          &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nx"&gt;serverProcess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unit test is as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Wait for server to be ready by making health check requests
 */&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForServer&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server is ready!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server failed to become ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Server not ready yet&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server failed to become ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;useObject Hook Tests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="nf"&gt;before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stopServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;


  &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test Server is Running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;should ensure the test environment is set up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server process should be running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverProcess&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;killed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server process should not be killed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test Entity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Data Loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;should load data successfully when entityParam is provided&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderHook&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
          &lt;span class="nf"&gt;useObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;entityParam&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntityName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Test&lt;/span&gt;
          &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Initially should be in loading state&lt;/span&gt;
        &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Wait for data to load&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Verify final state&lt;/span&gt;
        &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To conduct the test, it is necessary to initialize a simulated DOM beforehand. This is achieved by passing the &lt;code&gt;–import&lt;/code&gt; switch to the tsx utility. The command required is as follows (adjust for the actual .env location in your case):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm run build &amp;amp;&amp;amp; tsx --enable-source-maps --import=global-jsdom/register --test --env-file ../../.env.local"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All sources are compiled in this &lt;a href="https://gist.github.com/gsbelarus/c2d87221bc8f6b09e67f081516e2ff59" rel="noopener noreferrer"&gt;gist&lt;/a&gt; for your convenience.&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>testing</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
