Тонкий фронт-слой для веб-сервера winow: интерактивный интерфейс без сборщика JS.
htmx и Alpine.js поставляются вместе с пакетом и раздаются как статика, поэтому в проекте не появляется ни node_modules, ни шага сборки. Сервер отдаёт HTML - htmx подставляет его в нужный элемент страницы, Alpine.js отвечает за локальное состояние вроде раскрытого меню. Всё, что нужно от сервера: понять, что запрос пришёл от htmx, сформировать фрагмент разметки и выставить пару заголовков.
sequenceDiagram
participant Browser as браузер
participant Winow as winow
participant View as winow-view
Browser->>Winow: hx-post /задачи
Winow->>View: запрос
View-->>Browser: фрагмент HTML<br/>HX-Trigger: задачаДобавлена
opm install winow-view
Один обработчик обслуживает оба случая - это основной приём htmx: ссылку можно открыть в новой вкладке, и она отдаст полноценную страницу.
#Использовать winow
#Использовать winow-view
Перем ViewRenderer;
&Контроллер("/задачи")
Процедура ПриСозданииОбъекта()
ViewRenderer = View.СоздатьОтображения("шаблоны", Новый Структура("Макет", "макет"));
КонецПроцедуры
&ТочкаМаршрута("список")
Процедура Список(Запрос, Ответ) Экспорт
Модель = Новый Структура("Задачи", ПрочитатьЗадачи());
Ответ.УстановитьТипКонтента("html");
Если View.ЭтоHxЗапрос(Запрос.Заголовки) Тогда
Ответ.ТелоТекст = ViewRenderer.Фрагмент("задачи/список", Модель);
Иначе
Ответ.ТелоТекст = ViewRenderer.Страница("задачи/список", Модель);
КонецЕсли;
КонецПроцедурыАннотаций HTTP-метода в winow нет: метод запроса приходит параметром МетодЗапроса, поэтому обработка формы живёт в той же точке маршрута.
&ТочкаМаршрута("список")
Процедура Список(Запрос, Ответ, МетодЗапроса) Экспорт
Если МетодЗапроса = "POST" Тогда
Задача = ДобавитьЗадачу(Запрос.ПараметрыИменные["название"]);
Ответ.ТелоТекст = ViewRenderer.Фрагмент("задачи/строка", Задача);
// Клиент узнает о добавлении и сам обновит счётчик
View.УстановитьТриггер(Ответ.Заголовки, "задачаДобавлена",
Новый Структура("всего", КоличествоЗадач()));
Возврат;
КонецЕсли;
// ... вывод списка
КонецПроцедурыЗаголовки ответа возвращают то же соответствие, что получили, поэтому вызовы складываются в цепочку:
View.ПереопределитьЦель(View.ПереопределитьСпособЗамены(Ответ.Заголовки, "outerHTML"), "#ошибки");Чтения заголовков нечувствительны к регистру имени - это не мелочь: HTTP/2 передаёт имена только в нижнем регистре, а прокси нормализуют их по-своему.
Шаблоны - синтаксис JinjOS: {{ Модель.Поле }} подставляет значение, {% ... %} вставляет код OneScript.
шаблоны/задачи/список.html:
<ul id="задачи">
{% Для Каждого Задача Из Модель.Задачи Цикл %}
<li>{{ Задача.Название }}</li>
{% КонецЦикла; %}
</ul>шаблоны/макет.html - общая обёртка, содержимое встаёт на место @Контент:
<!doctype html>
<html>
<head>
<title>{{ Модель.Заголовок }}</title>
<!-- сюда подставятся теги htmx и Alpine.js -->
</head>
<body>@Контент</body>
</html>Макет получает ту же модель, что и содержимое, поэтому заголовок страницы и данные пользователя доступны в нём напрямую.
ViewRenderer = View.СоздатьОтображения("шаблоны", Новый Структура("Макет", "макет"));
ViewRenderer.Фрагмент("задачи/список", Модель); // без макета
ViewRenderer.Страница("задачи/список", Модель); // в макете по умолчанию
ViewRenderer.Страница("задачи/список", Модель, "печать"); // в другом макете
ViewRenderer.ОтрендеритьТекст("<b>{{ Модель.Имя }}</b>", Модель); // без файлаРасширение в имени можно опустить, подкаталоги разрешены. На время разработки отключите кэш, чтобы правки шаблонов подхватывались без перезапуска:
ViewRenderer = View.СоздатьОтображения("шаблоны", Новый Структура("КэшироватьШаблоны", Ложь));Отсутствующий шаблон сообщает и имя, и путь, по которому его искали:
winow-view: шаблон задачи/строка не найден, искали файл C:\app\шаблоны\задачи\строка.html
Шаблонизатор подставляет значения как есть, поэтому всё, что пришло от пользователя, экранируется явно:
<li title="{{ Модель.Подсказка }}">{{ Модель.Название }}</li>Модель = Новый Структура();
Модель.Вставить("Название", View.ЭкранироватьHtml(Задача.Название));
Модель.Вставить("Подсказка", View.ЭкранироватьАтрибут(Задача.Комментарий));ЭкранироватьАтрибут дополнительно снимает обратную кавычку - в атрибуте без кавычек она работает ограничителем значения в некоторых браузерах. Значение атрибута всё равно берите в кавычки.
Каталог с библиотеками достаточно зарегистрировать в winow как каталог статичных файлов, указав View.ПутьКСтатике(). Если файлы раздаёт сам обработчик:
&ТочкаМаршрута("static/{ИмяФайла}")
Процедура Библиотека(ИмяФайла, Ответ) Экспорт
Ответ.УстановитьТипКонтента("js");
Ответ.ТелоТекст = View.СодержимоеБиблиотеки(ИмяФайла);
КонецПроцедурыИмя проверяется по закрытому списку: СодержимоеБиблиотеки не прочитает ничего, кроме двух поставляемых файлов, даже если в имени приедет ../.
Теги для макета готовит ТегиПодключения:
Модель.Вставить("Скрипты", View.ТегиПодключения("/static"));<script src="/static/htmx.min.js"></script>
<script src="/static/alpine.min.js" defer></script>Alpine.js подключается с defer не для скорости: без него библиотека инициализируется раньше, чем разобран документ, и не находит разметку.
Токен не хранится на сервере: это метка времени и HMAC-SHA256 от секрета по паре «метка времени + идентификатор сессии». Поэтому схема переживает перезапуск приложения и работает при нескольких рабочих процессах без общего хранилища.
Защита = View.СоздатьЗащитуCSRF(ПолучитьПеременнуюСреды("APP_SECRET"));
// при выводе формы
Модель.Вставить("ПолеCSRF", Защита.ПолеФормы(Запрос.Сессия.Идентификатор()));<form hx-post="/задачи/список">
{{ Модель.ПолеCSRF }}
<input name="название">
</form>// при обработке
Токен = Запрос.ПараметрыИменные[Защита.ИмяПоля()];
Если Не Защита.ПроверитьТокен(Токен, Запрос.Сессия.Идентификатор()) Тогда
Ответ.УстановитьСостояние(403);
Возврат;
КонецЕсли;Для запросов htmx токен удобно передавать заголовком сразу для всех форм страницы:
<body hx-headers='{"X-CSRF-Token": "{{ Модель.ТокенCSRF }}"}'>Токен = Защита.ТокенИзЗаголовков(Запрос.Заголовки);ПроверитьТокен не выбрасывает исключений: любое неожиданное значение - это просто непройденная проверка. Подписи сравниваются за постоянное время, поэтому по длительности проверки подпись не подобрать.
Заголовки запроса:
| Метод | Возвращает | Заголовок |
|---|---|---|
ЭтоHxЗапрос(Заголовки) |
Булево |
HX-Request |
ЭтоHxBoosted(Заголовки) |
Булево |
HX-Boosted |
ЦельHx(Заголовки) |
Строка |
HX-Target |
ТриггерHx(Заголовки) |
Строка |
HX-Trigger |
ИмяТриггераHx(Заголовки) |
Строка |
HX-Trigger-Name |
ТекущийАдресHx(Заголовки) |
Строка |
HX-Current-URL |
ЭтоЗапросИсторииHx(Заголовки) |
Булево |
HX-History-Restore-Request |
ЗапросПодтвержденияHx(Заголовки) |
Строка |
HX-Prompt |
ЗначениеЗаголовка(Заголовки, Имя, ЗначениеПоУмолчанию = "") |
Строка |
любой |
Заголовки ответа - все возвращают то же Соответствие, что получили (Неопределено - создать новое):
| Метод | Заголовок |
|---|---|
УстановитьТриггер(Заголовки, Имя, Данные = Неопределено) |
HX-Trigger |
УстановитьТриггерПослеЗамены(Заголовки, Имя, Данные = Неопределено) |
HX-Trigger-After-Swap |
УстановитьТриггерПослеУстановки(Заголовки, Имя, Данные = Неопределено) |
HX-Trigger-After-Settle |
Перенаправить(Заголовки, Адрес) |
HX-Redirect |
Обновить(Заголовки) |
HX-Refresh |
ЗаменитьАдрес(Заголовки, Адрес) |
HX-Replace-Url |
ПротолкнутьАдрес(Заголовки, Адрес) |
HX-Push-Url |
ПереопределитьЦель(Заголовки, Селектор) |
HX-Retarget |
ПереопределитьСпособЗамены(Заголовки, Способ) |
HX-Reswap |
ПереопределитьВыборку(Заголовки, Селектор) |
HX-Reselect |
Событие без данных отправляется простым именем (HX-Trigger: задачаДобавлена). Как только появляются данные или второе событие, заголовок становится объектом JSON: {"первое":null,"второе":{"всего":3}}.
Остальное:
| Метод | Возвращает | Описание |
|---|---|---|
ЭкранироватьHtml(Текст) |
Строка |
Экранирование для текста элемента |
ЭкранироватьАтрибут(Текст) |
Строка |
Экранирование для значения атрибута |
ПутьКСтатике() |
Строка |
Каталог с htmx и Alpine.js |
ФайлБиблиотеки(Имя) |
Строка |
Путь к файлу библиотеки |
СодержимоеБиблиотеки(Имя) |
Строка |
Текст файла библиотеки |
ТегиПодключения(Префикс = "/static") |
Строка |
Теги <script> |
ВерсииБиблиотек() |
Соответствие |
Версии htmx и Alpine.js |
СоздатьОтображения(КаталогШаблонов, Параметры = Неопределено) |
ViewRenderer |
Объект рендеринга |
СоздатьЗащитуCSRF(Секрет, СрокЖизни = 3600) |
CsrfGuard |
Защита форм |
Отрендерить(ИмяШаблона, Модель), Фрагмент(ИмяШаблона, Модель), Страница(ИмяШаблона, Модель, Макет = Неопределено), ОтрендеритьТекст(ТекстШаблона, Модель), Существует(ИмяШаблона), ПутьШаблона(ИмяШаблона), ОчиститьКэш(); свойства КаталогШаблонов, МакетПоУмолчанию.
Фрагмент - это Отрендерить под именем, читаемым в коде обработчика htmx: возвращается разметка без макета.
| Параметр | По умолчанию | Описание |
|---|---|---|
КэшироватьШаблоны |
Истина |
Хранить скомпилированные шаблоны в памяти |
Расширение |
".html" |
Дописывается к имени без расширения |
Макет |
- | Макет по умолчанию для Страница |
ПлейсхолдерКонтента |
"@Контент" |
Место вставки содержимого в макет |
ПутьКJinjOS |
- | Путь к src/Классы/Шаблон.os пакета JinjOS |
СоздатьТокен(ИдентификаторСессии, МеткаВремени = Неопределено), ПроверитьТокен(Токен, ИдентификаторСессии, ТекущаяМетка = Неопределено), ПолеФормы(ИдентификаторСессии), ИмяПоля(), ИмяЗаголовка(), ТокенИзЗаголовков(Заголовки); свойство СрокЖизниТокена.
Явная метка времени делает выдачу и проверку воспроизводимыми - это нужно в тестах и при разборе инцидентов.
| Библиотека | Версия | Лицензия | Источник |
|---|---|---|---|
| htmx | 2.0.10 | 0BSD | unpkg.com/htmx.org@2.0.10/dist/htmx.min.js |
| Alpine.js | 3.15.12 | MIT | cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js |
Файлы лежат в src/static без изменений, ровно как их отдаёт CDN. Версия проверяется тестом: объявленная в ВерсииБиблиотек() должна совпадать с версией внутри файла, иначе README и то, что реально уходит браузеру, разъедутся.
Обе лицензии разрешают распространение в составе пакета; 0BSD не требует даже сохранения текста лицензии.
Синтаксис JinjOS строже, чем кажется. Внутри {% %} операторы завершаются точкой с запятой ({% КонецЦикла; %}), выражение в {{ }} не может содержать }, а блок {% %} - знак процента. Плейсхолдер макета не должен выглядеть как подстановка: {{{контент}}} шаблонизатор попытается вычислить.
Имя класса Шаблон в экосистеме занято дважды - в JinjOS и в winow, - и достаётся библиотеке, подключённой первой. В приложении с #Использовать winow выражение Новый Шаблон(...) вернёт класс winow, рассчитанный на внедрение зависимостей контейнером autumn. Поэтому пакет не полагается на глобальное имя: файл шаблонизатора подключается напрямую и регистрируется под своим. Если JinjOS установлен нестандартно, укажите путь к нему параметром ПутьКJinjOS.
Имя шаблона и имя библиотеки нередко приходят из запроса. Имена с .., двоеточием и ведущим слешем отвергаются, файлы статики ограничены закрытым списком: через них нельзя прочитать произвольный файл на диске.
Секрет CSRF задаётся извне. Один и тот же у всех рабочих процессов, разный в разных окружениях, в исходный код не попадает. Токен привязан к сессии, поэтому сессии нужны включёнными.
opm install -l
oneunit execute -d ./tests