Когда возникает идея создать браузерный 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"><alice> hello from A</div> <div slot="users" class="usr usr-a">alice</div> <div slot="messages" class="msg msg-b"><bob> hello from B</div> <div slot="users" class="usr usr-b">bob</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> 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"><alice> hello from A</div> <div slot="users" class="usr usr-a">alice</div> <div slot="messages" class="msg msg-b"><bob> hello from B</div> <div slot="users" class="usr usr-b">bob</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> hello from A</div> <div slot="messages" class="msg msg-a"><alice> 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-канале ↩

