Когда возникает идея создать браузерный 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-канале ↩

Комментарии (3)

Whitepaper21122012
08.06.2026 08:44тикет 12080973
уже сутки не работает сервер
поддержка ничего не отвечает, на телефоне робот, говорит пишите на почту
AdrianoVisoccini
Офигеть, даже не задумывался что так можно. Автор как всегда доставляет контентище
unreal_undead2
На CSS можно даже так.