Когда возникает идея создать браузерный IRC-клиент без JavaScript, приходится сталкиваться с классической проблемой фронтенда: все насколько привыкли гнать динамику через JavaScript, что перестали замечать возможности HTML/CSS с щепоткой серверной магии по реализации многих фич. HTTP Streaming существует с давних времён, а CSS эволюционировал настолько, что может справиться с логикой состояний — но мы упорно продолжаем грузить мегабайты JavaScript (и иногда даже WebAssembly) для решений, которые вполне можно реализовать иначе.

Идея создать IRC клиент без JavaScript не совсем нова (хоть это и выяснилось уже после создания такого :) ). Ещё в нулевых появился CGI:IRC — настоящий IRC клиент, который может работать полностью без JavaScript, позволяя людям общаться в реальном времени через браузер, даже если JavaScript по каким-то причинам не работал. Но это было в эру table-layouts, и когда CSS не был так развит, как сейчас. Сегодня возможностей больше, и мы воспользуемся ими, чтобы навернуть функциональность, которая не видана CGI:IRC.

Эта статья не про HTTP Streaming в чистом виде — это будет лишь основой. В статье будет про сочетание большого количества HTML/CSS трюков, которые браузер позволит делать. В итоге получится функциональный IRC, где будут:

  • Динамическое создание новых каналов

  • Обновление сообщений в реальном времени без перезагрузки

  • Отправка сообщений

  • Уведомление пользователя при обвале соединения

  • Динамически обновляемый список пользователей в каналах

И да — долгоживущее соединение держится только одно на клиента.

Результат можно глянуть (хоть и с дополнительной стилизацией и изменениями, которые не так важны для статьи) здесь, а ещё на GitHub

❯ Основа

Сообщения можно получать через HTTP Streaming. Суть проста: соединение не прекращается, а остаётся долгоживущим, тогда как сервер дописывает новые HTML блоки в соединение, используя Transfer-Encoding: chunked. Это работает, но у этого есть свои ограничения. Уже прибывший HTML/CSS не изменить, но даже с такими ограничениями создать полноценный IRC клиент вполне реально.

Сначала нужно решить, что мы вообще делаем сейчас. А сделать нужно следующее — создать окно чата с сообщениями слева и списком пользователей справа, сверху — кнопки выбора канала, которые скрывают старые данные и показывают новые.

Можно рассмотреть различные пути решения проблемы. Например, создавать отдельный iframe с долгоживущими соединениями на каждый канал. Один iframe для пользователей, для сообщений — другой. С кнопками одни будут скрываться, а другие показываться. Но у такого подхода сразу видны проблемы. У браузера есть ограничения по количеству одновременных соединений на один домен (обычно 6-8), а каждое соединения отдельно, и проверять тоже нужно отдельно, что увеличивает нагрузку на сервер.

Но если взять константное количество соединений и несколько каналов, то решение не так просто. Если взять 2 разных iframe для сообщений и пользователей, как в предыдущем подходе, то ограничивает сама работа iframe — внутрь iframe без JavaScript не получится передать статус активной кнопки силами браузера, хотя и получится силами сервера. Можно отправлять запрос на сервер, чтобы сервер понимал, какой канал сейчас смотрит пользователь, благодаря чему сможет отправить блоки для скрытия старого и отображения нового. У этого подхода тоже проблема: сервер будет знать больше информации, чем ему положено, а соединений будет 2 вместо одного, хоть это уже не столь значимые проблемы, по сравнению с теми, что были с предыдущим подходом.

Если сервер будет иметь больше информации, то для нас разработчиков это просто ещё одна строка состояния, а для пользователя это некоторая потеря приватности, которой хотелось бы избежать.

Из введения следует, что есть и третий вариант, и он из разряда тех, что выглядят как ненормальный чит, но ведь именно за это эта статья и в “Ненормальном программировании” :). С одной стороны, даже сама возможность существования такого варианта кажется противоречивой: соединение одно, в один HTML-блок идут и пользователи, и сообщения, а прокрутка их отдельная.

Обычно раздельная прокрутка потребовала бы два полноценных отдельных блока, но можно обойтись и “виртуальными”. Для создания таких блоков можно использовать template вместе с Declarative Shadow DOM, который был добавлен в современный HTML не так давно.

Подход

Проблемы

По несколько iframe на каждый канал

Ограничение браузера на соединения (6-8), отдельная проверка каждого соединения, нагрузка на сервер

Два iframe

Не получится передать статуса активной кнопки без JS (подход не работает)

Два iframe + кнопка для каждого канала

Сервер знает слишком много, а долгоживущих соединений больше необходимого

Declarative Shadow DOM

Проблем нет

❯ Про Declarative Shadow DOM

Declarative Shadow DOM — это определение структуры Shadow DOM напрямую в HTML через <template shadowroot>, что позволяет использовать механизм слотов для правильного распределения контента: содержимое из основного DOM автоматически попадает в соответствующие <slot> элементы внутри Shadow DOM, без необходимости в JavaScript.

Выглядит его использование в текущем контексте так:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Chat</title>
</head>
<body style="margin: 0; height: 100vh;">

<web-client>
  <template shadowrootmode="open">
    <style>
      :host {
        display: flex;
        width: 100%;
        height: 100%;
      }
      .messages-col {
        flex: 1;
        overflow-y: auto;
      }
    </style>

    <div class="messages-col">
      <slot name="messages"></slot>
    </div>
    <div class="users-col">
      <slot name="users"></slot>
    </div>
  </template>

  <div slot="messages">Message 1</div>
  <div slot="users">User 1</div>
  <div slot="messages">Message 2</div>
  <div slot="users">User 2</div>
  <div slot="messages">Message 3</div>
  <div slot="users">User 3</div>
  <div slot="messages">Message 4</div>
  <div slot="messages">Message 5</div>
  <div slot="messages">Message 6</div>
  <div slot="messages">Message 7</div>
  <div slot="messages">Message 8</div>
</web-client>
</body>
</html>

Как видно из примера, можно отправлять сообщения и пользователей в разнобой, нужно лишь указывать им нужный slot, а они сами будут распределяться куда нужно. Раздельная прокрутка при этом тоже работает.

А раз HTML сам раскидывает всё по полочкам, мы можем просто доливать новые куски в один поток, а такая возможность и нужна в рамках HTTP Streaming.

❯ Переключение каналов

С основой разобрались. Теперь — переключение каналов.

Можно переключать каналы через радиокнопки. С таким подходом будут скрытые блоки <input type="radio">, которые привязаны к соответствующим им <label>. При выборе радиокнопки, селектор :checked ~ .content показывает соответствующий контент. Но у этого подхода есть недостатки: не получится сделать автоматическую прокрутку в самый низ канала при нажатии на его кнопку, из-за чего придётся листать вручную.

Но есть альтернативный подход, который таких недостатков лишён. Его идея — использовать якорные ссылки ( href="#id" ) в сочетании с CSS селектором :has(:target) для отображения нужного контента при нажатии. Внизу каждого канала размещается скрытый якорь. Браузер автоматически скроллит к элементу с этим id, поэтому скролл всегда оказывается в нужном месте.

Для этого родительский контейнер делается флекс-контейнером, и якорю устанавливается order: 2147483647 — максимальное безопасное значение, чтобы он всегда был в конце, даже если в контейнер добавляются новые элементы через HTTP Streaming.

Минимальный пример для демонстрации идеи с якорями, после чего нужно будет совместить это с Declarative Shadow DOM из прошлого раздела:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body {
      margin: 0;
      height: 100vh;
      display: grid;
      grid-template-rows: auto 1fr;
    }

    nav {
      position: sticky;
      top: 0;
      z-index: 1000;
      background: white;
    }

    .container {
      flex: 1;
      min-height: 0;
      display: flex;
      flex-direction: column;
    }

    .channel {
      flex: 1;
      min-height: 0;
      flex-direction: column;
    }

    .messages {
      flex: 1;
      min-height: 0;
      overflow-y: auto;
      display: flex;
      flex-direction: column;
    }

    .message {
      display: none;
    }

    .container:has(#ch1:target) .message.ch1,
    .container:has(#ch2:target) .message.ch2 {
      display: block;
    }

    .anchor {
      order: 2147483647;
      height: 0;
      padding: 0;
      margin: 0;
    }
  </style>
</head>
<body>

<nav>
  <a href="#ch1">Channel 1</a>
  <a href="#ch2">Channel 2</a>
</nav>

<div class="container">
  <div class="channel">
    <div class="messages">
      <div class="anchor" id="ch1"></div>
      <div class="anchor" id="ch2"></div>
      <div class="message ch1">Message 1</div>
      <div class="message ch2">Message A</div>
      <div class="message ch1">Message 2</div>
      <div class="message ch2">Message B</div>
      <div class="message ch1">Message 3</div>
      <div class="message ch2">Message C</div>
      <div class="message ch1">Message 4</div>
      <div class="message ch1">Message 5</div>
      <div class="message ch1">Message 6</div>
      <div class="message ch1">Message 7</div>
      <div class="message ch1">Message 8</div>
    </div>
  </div>
</div>

</body>
</html>

Возможность закидывать сообщения в любом порядке не назвать нормальной, но тут она принципиально важна. Если бы для каждого канала создавался отдельный блок, все каналы пришлось бы задавать заранее через Declarative Shadow DOM и уже потом распределять. Но тогда бы не получилось сделать динамическое добавление новых каналов. Со списком каналов же такой проблемы нет — структура всего одна, и его можно поместить через template, после чего добавлять новые элементы в Light DOM.

Осталось соединить это вместе с предыдущей частью. Ключевой момент: из-за ограничений HTTP Streaming весь CSS для каждого канала должен находиться внутри элемента web-client, после template. Это необходимо для динамического создания канала. Так как можно дописывать HTML-блоки только в конце, то динамически в template ничего не добавить.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>IRC</title>
<style>
  body { margin: 0; height: 100vh; display: flex; flex-direction: column; }
  .msg, .usr, .anchor { display: none; }
  .anchor {
    order: 2147483647;
    height: 1px;
    min-height: 1px;
    overflow: hidden;
  }
</style>
</head>
<body>

<web-client>
  <template shadowrootmode="open">
    <style>
      :host {
        display: flex;
        flex-direction: column;
        flex: 1;
        min-height: 0;
      }
      .chrome { display: flex; gap: 4px; padding: 4px; }
      .panes {
        display: flex;
        flex: 1;
        min-height: 0;
      }
      .messages-col {
        flex: 1;
        display: flex;
        flex-direction: column;
        min-height: 0;
      }
      .messages-scroll {
        flex: 1;
        overflow-y: auto;
        display: flex;
        flex-direction: column;
      }
      .users-col {
        overflow-y: auto;
        display: flex;
        flex-direction: column;
      }
    </style>
    <div class="chrome"><slot></slot></div>
    <div class="panes">
      <div class="messages-col">
        <div class="messages-scroll"><slot name="messages"></slot></div>
      </div>
      <div class="users-col"><slot name="users"></slot></div>
    </div>
  </template>

  <style>
    /* fallback: показываем #a, если ни один якорь не активен */
    web-client:not(:has(:target)) .msg-a,
    web-client:not(:has(:target)) .usr-a,
    web-client:not(:has(:target)) #anchor-a { display: block; }

    web-client:has(#anchor-a:target) .msg-a,
    web-client:has(#anchor-a:target) .usr-a,
    web-client:has(#anchor-a:target) #anchor-a { display: block; }

    web-client:has(#anchor-b:target) .msg-b,
    web-client:has(#anchor-b:target) .usr-b,
    web-client:has(#anchor-b:target) #anchor-b { display: block; }
  </style>

  <a href="#anchor-a">a</a>
  <a href="#anchor-b">b</a>

  <div slot="messages" id="anchor-a" class="anchor"></div>
  <div slot="messages" id="anchor-b" class="anchor"></div>

  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="users" class="usr usr-a">alice</div>

  <div slot="messages" class="msg msg-b">&lt;bob&gt; hello from B</div>
  <div slot="users" class="usr usr-b">bob</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
</web-client>

</body>
</html>

Звучит безумно, но всё необходимое для добавления канала — сгенерировать его стили, якорь и кнопку:

<style>
  web-client:has(#anchor-a:target) .msg-a,
  web-client:has(#anchor-a:target) .usr-a,
  web-client:has(#anchor-a:target) #anchor-a { display: block; }
</style>
<a href="#anchor-a">#a</a>
<div slot="messages" id="anchor-a" class="anchor"></div>

Нужно лишь сменить канал на нужный для генерации.

❯ Отправка сообщений

С получением и отображением сообщений разобрались, теперь вопрос — как отправлять. Благо, в HTML есть обычные <form>, которые могут работать без JS вполне себе. Не будь их, пришлось бы изобретать что-то наподобие того, что используется в css-only-chat.

Но у наивного подхода с формами есть свои проблемы. Сообщение не пропадает из поля ввода при отправке, из-за чего пользователю приходится стирать его вручную — неудобно. Чтобы текст исчезал, отправляем форму в iframe, а сервер отдаёт ту же страницу с чистым полем — фактически перезагружая страницу формы в _self внутри iframe. Но и у этого есть свои проблемы — фокус с поля слетает, из-за чего его нужно заново выбирать. Впрочем, это можно делать одним нажатием клавиши Tab, что уже значительно проще предыдущего варианта, так что остановимся на текущем.

Внутри формы спрятано поле с UUID для идентификации пользователя.

<form action="/send?channel=test" method="post" target="_self">
  <input type="hidden" name="connection_id" value="some-uuid">
  <input type="text" name="message" placeholder="Message for #test" autocomplete="off">
  <button>Send</button>
</form>

При добавлении канала генерируем дополнительно следующее:

<iframe slot="composer" class="frame-a" src="/send_message?channel=test" style="border:none;width:100%;height:36px;background:transparent;" scrolling="no"></iframe>

Для этого нужно будет изменить саму страницу и template, добавив туда composer. В итоге html страница приобретает следующий вид (плюс некоторые украшательства):

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>IRC without JavaScript</title>
<style>
  body {
    margin: 0;
    height: 100vh;
    display: flex;
    flex-direction: column;
    background: #111;
    color: #bbb;
    font-family: system-ui, sans-serif;
  }

  .msg, .usr, .anchor, .composer { display: none; }

  .anchor {
    order: 2147483647;
    height: 1px;
    min-height: 1px;
    overflow: hidden;
    flex-shrink: 0;
  }

  a {
    color: #888;
    text-decoration: none;
    padding: 2px 10px;
    border: 1px solid #444;
  }

  .msg { padding: 2px 6px;  }
  .usr { padding: 2px 10px; font-size: 12px; }
</style>
</head>
<body>

<web-client>
  <template shadowrootmode="open">
    <style>
      :host {
        display: flex;
        flex-direction: column;
        flex: 1;
        min-height: 0;
      }
      .chrome {
        display: flex;
        gap: 4px;
        padding: 8px;
        border-bottom: 1px solid #333;
        flex-shrink: 0;
      }
      .panes {
        display: flex;
        flex: 1;
        min-height: 0;
        overflow: hidden;
      }
      .messages-col {
        flex: 1;
        display: flex;
        flex-direction: column;
        min-height: 0;
      }
      .messages-scroll {
        flex: 1;
        overflow-y: auto;
        display: flex;
        flex-direction: column;
      }
      .users-col {
        overflow-y: auto;
        border-left: 1px solid #333;
        flex-shrink: 0;
        font-size: 12px;
      }
      .composer-slot {
        flex-shrink: 0;
        height: 36px;
        border-top: 1px solid #333;
      }
    </style>

    <div class="chrome"><slot></slot></div>
    <div class="panes">
      <div class="messages-col">
        <div class="messages-scroll"><slot name="messages"></slot></div>
      </div>
      <div class="users-col"><slot name="users"></slot></div>
    </div>
    <div class="composer-slot"><slot name="composer"></slot></div>
  </template>


  <style>
    web-client:not(:has(:target)) .msg-a,
    web-client:not(:has(:target)) .usr-a,
    web-client:not(:has(:target)) #anchor-a,
    web-client:not(:has(:target)) .composer-a { display: block; }

    web-client:has(#anchor-a:target) .msg-a,
    web-client:has(#anchor-a:target) .usr-a,
    web-client:has(#anchor-a:target) #anchor-a,
    web-client:has(#anchor-a:target) .composer-a { display: block; }

    web-client:has(#anchor-b:target) .msg-b,
    web-client:has(#anchor-b:target) .usr-b,
    web-client:has(#anchor-b:target) #anchor-b,
    web-client:has(#anchor-b:target) .composer-b { display: block; }
  </style>

  <a href="#anchor-a">a</a>
  <a href="#anchor-b">b</a>


  <div slot="messages" id="anchor-a" class="anchor"></div>
  <div slot="messages" id="anchor-b" class="anchor"></div>

  <iframe slot="composer" class="composer composer-a"
          src="/send_message?channel=a" style="border:none;width:100%;height:100%;" scrolling="no"></iframe>
  <iframe slot="composer" class="composer composer-b"
          src="/send_message?channel=b" style="border:none;width:100%;height:100%;" scrolling="no"></iframe>

  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="users" class="usr usr-a">alice</div>

  <div slot="messages" class="msg msg-b">&lt;bob&gt; hello from B</div>
  <div slot="users" class="usr usr-b">bob</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
  <div slot="messages" class="msg msg-a">&lt;alice&gt; hello from A</div>
</web-client>

</body>
</html>

Теперь есть и отправка сообщений.

❯ Скрытие пользователей

Из-за ограничений работы HTTP Streaming полностью удалить пользователя из DOM не выйдет, но вот скрыть пользователя можно вполне. Нужно лишь выцепить конкретного пользователя в конкретном месте для скрытия. Для этого пользователям можно добавлять дополнительно класс user-channel-username, который и будет служить для наших целей.

Для скрытия достаточно прислать такой блок:

<style>web-client:has(#anchor-a:target) .user-a-bob { display:none !important }</style>

Чтобы вернуть пользователя обратно можно отправить то же самое, но уже с display: initial:

<style>web-client:has(#anchor-a:target) .user-a-bob { display:initial !important }</style>

❯ Проверка статуса соединения

При обычной работе с HTTP Streaming напрямую, браузер показывает бесконечную загрузку страницы, которая прекращается при обрыве соединения. Но это не то чтобы очень заметно, вид загрузки страницы может мешать при работе со страницей, и он пропадёт в случае открытия страницы внутри iframe.

Нужна альтернатива, и такой альтернативой может служить iframe, где будет <meta http-equiv="refresh" content="30">, обновляющий страницу статуса каждые 30 секунд. Почему именно этот вариант? Можно было бы держать другую страницу со стримингом и отправлять сообщение о поломке туда, но при обрыве и эта страница отвалится, в противном случае ничего не отобразится. Что произойдет в случае полного отвала соединения вместе с http-equiv="refresh"? Отобразится белая страница, что уведомит пользователя о состоянии соединения.

На главную страницу достаточно добавить такое:

<iframe src="/connection_status?connection_id=uuid" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:300px;height:120px;border:none;z-index:1000;background:transparent;pointer-events:none;"></iframe>

❯ Ограничения подхода

Хоть CSS/HTML и достаточно мощны, но они имеют свои ограничения. Так подведём итоги по ограничениям подходов, применяемых в статье:

  • Фокус сбрасывается при отправке сообщения

  • Форма в iframe мигает при отправке

  • При полном обрыве связи посреди экрана вылезет белый прямоугольник — не самая интуитивная подсказка

  • Старые сообщения и пользователей нельзя вычистить из DOM, поэтому он разрастается, занимая всё больше памяти.

  • Declarative Shadow DOM появился не так давно, так что старые браузеры отсеиваются

❯ Заключение

В итоге мы получили работающий IRC-клиент: одно долгоживущее HTTP соединение, динамическое отображение каналов, обновление сообщений и пользователей в реальном времени, отправка текста через формы и даже индикация потери связи. И всё это — без единой строчки клиентского JavaScript. Declarative Shadow DOM раскладывает потоковые данные по полочкам, CSS :has(:target) переключает каналы, а сервер просто дописывает HTML в бесконечный chunked-поток.

Но давайте откровенно: это не решение для обычного продакшена. Это — эксперимент на грани возможного, способ проверить, сколько логики можно вытеснить в связку HTML/CSS и серверной логики работы безумными методами. Для полноценной замены JS на фронтенде у подхода слишком много ограничений: DOM непрерывно растёт и никогда не чистится, форма отправки мигает и теряет фокус, а для работы необходим современный браузер с поддержкой :has() и Declarative Shadow DOM. Текстовые браузеры с такой вёрсткой не справятся, а слабые машины рано или поздно начнут задыхаться под весом накапливающегося DOM.

Тем не менее, этот проект ценен именно как исследование. Мы настолько привыкли к тому, что динамика в браузере === JavaScript, что перестали замечать: HTML-парсер, CSS-движок и одно долгоживущее HTTP-соединение уже содержат в себе огромный потенциал декларативной логики. Этот клиент — не призыв отказаться от JS во всех проектах, а скорее напоминание о том, что границы между слоями веб-платформы постоянно сдвигаются, и иногда 80% задачи решаются без единого скрипта.

На этом же HTTP Streaming работал Bad Apple из предыдущей статьи.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале