
Привет! Меня зовут Артём. Я руководитель группы Scala-разработчиков в компании «Криптонит», бэкенд-разработчиков, если быть точнее. Мы пишем на Typelevel стеке с использованием Cats Effect. Я знал о том, что можно писать фронтенд на Scala, но не делал этого, пока не занялся небольшим pet-проектом сервис "План развития". По пути я обнаружил множество “подводных камней” и сделал для себя ряд выводов. Об этом интересном опыте и будет моя статья.
Как написать бэк на Typelevel стеке большинство знают — этому посвящены сотни ресурсов: книги, статьи, курсы... В частности, этому посвящена книга “Practical FP in Scala” Габриэля Вольпе (Gabriel Volpe), где описывается создание интернет-магазина. Но как визуализировать этот магазин?
Как скрестить фронт и Scala тоже известно: У Play framework более 12 тысяч звёзд на гитхабе. На этом фреймворке успешно написаны сотни продуктов. Однако Play — это классический ООП-фреймворк, плюс он основан на Pekko/Akka, а мне хотелось писать в функциональном стиле на Typelevel стеке, где под капотом cats effect и htttp4s.
Первое решение — а почему бы не навайбкодить фронт?! Но у вайбкодинга есть определённая граница, после пересечения которой ты уже перестаёшь понимать проект, а количество багов очень быстро растёт. Всё это приводит к демотивации и потере энтузиазма:

А мне всё же хотелось отбросить костыли и писать фронт самому.
Поэтому я начал с изучения того, что есть на рынке и можно использовать с Typelevel стеком. Выбор большой:
Laminar - 832✯ на github
Slinky - 671✯
Outwatch - 469✯
Calico - 138✯
Krop - 67✯
Tyrian - 64✯
ff4s - 48✯
Forms4s - 24✯
+ с десяток заброшенных библиотек
Есть где разгуляться, но проблема в том, что я перешёл к выбору frontend-фреймворка тогда, когда у меня уже был готов backend, поэтому по сути я не выбирал "лучший из лучших", а выбирал тот фреймворк, с которым смог бы легко интегрировать свой backend. Натягивал сову на глобус. Попробовав все фреймворки, я выбрал Krop — библиотеку, которую пишет Ноэл Уэлш (Noel Welsh), автор книг Creative Scala и Scala with Cats. Библиотеку он разрабатывает в одиночку, что накладывает определённые риски. Он активно выступает с рассказами об этом фреймворке, в частности — говорил о нём на Scala Days в 2025 году:

Запускаемся
Для проекта существует шаблон, который быстро разворачивается и приводит на начальную страницу http://localhost:8080/


Это всё! Как это часто бывает с библиотеками, которые ведёт один человек, примеров использования в документации нет. Тайные знания сокрыты этим единственным разработчиком. Скорее всего, он даже успешно использует Krop в нескольких проектах, но остальные об этом не узнают — нет доки. А ведь первым же предложением на основной странице Krop написано: "Krop is a Scala 3 web framework. Its goal is to make it delightful to build delightful web applications."
Где же этот приятный процесс?!
Поискав по исходникам и другим проектам автора, я нашёл примеры использования htmx и websocket.
Но пример для htmx занимает всего 50 строк кода и выдаёт страничку с парой полей:

Второй пример запускается с ошибкой. Описания нет.
Но opensource проекты живут не только благодаря авторам, но и благодаря задротам энтузиастам, которые приходят и помогают дорабатывать проекты.

Создание приложения для авторизации
Уверовав в то, что вариант со второй по популярности библиотекой в JavaScript — Htmx — может что-то дать, я решил создать полноценное приложение для авторизации с использованием Krop, реализующее регистрацию пользователей, вход, выход и управление сессиями. А затем дополнить документацию Krop этим примером (спойлер, это удалось: теперь пример — часть документации Krop).
Приложение будет представлять собой полный поток аутентификации пользователя:
Страница входа – пользователи могут войти со своими учетными данными

Страница регистрации – новые пользователи могут создать учётную запись

Аутентифицированная панель управления – после входа пользователи увидят приветственную страницу со вкладками управления задачами

Управление сессиями – Токены пользователей будут храниться в cookies для постоянных сессий.
Структура проекта
Стандартная структура каталогов для проекта Krop похожа на стандартную структуру для Scala проекта, но в ней отсутствуют каталоги src/main/scala, src/main/resources и т.д. – вместо них, сразу src/$package$, что может сильно смутить разработчиков.
Если это непривычное расположение "не зайдёт", то всегда можно отключить плагин KropLayout, удалив .enablePlugins(KropLayout), и вернуть привычные каталоги.
Модели
Первое, с чего стоит начать — это определиться с моделями, которые нужны для нашей предметной области. У нас будет только одна модель: LoginRequest. Он определяет структуру данных для запросов аутентификации и используется как в потоке регистрации, так и в потоке входа. Для использования в post-запросе LoginRequest должен реализовывать класс типов FormCodec:
case class LoginRequest( username: String, password: String ) derives FormCodec
Выглядит всё лаконично и красиво, но есть нюанс. Предметную область я предпочитаю определять с помощью уточняющих типов, чтобы сделать невозможные состояния непредставимыми.
Например, вот как я бы определил тип параметра username с помощью iron:
type UserName = UserName.T // Строка от 2 до 20 символов object UserName extends RefinedType[String, MinLength[2] & MaxLength[20] & Trimmed]
Но в Krop есть особенность: если он не сможет распарсить ответ, то кинет на 404 страницу вместо предоставления возможности как-то обработать ошибку. Представьте себе пользователя, который ввёл "IWantToUseAVeryLongLogin" и его кидает на "Not Found" — типа несуществующий запрос, вместо того, чтобы рассказать о некорректном параметре. В результате в области моделей для роутов приходится использовать только примитивные типы, а парсить уже в хендлерах.
Маршруты
Далее определимся с маршрутами. Объект Routes в модуле shared определяет все конечные точки приложения с использованием DSL маршрутизации Krop. Каждый маршрут определяет:
HTTP-метод (GET, POST);
Шаблон пути URL;
Извлечение запроса (заголовки, парсинг тела);
Обработку ответов с соответствующими кодами состояния
Например:
object Routes: val index = Route( Request.get(Path.root).extractHeader[Cookie], Response.ok(Entity.html) ) val login = Route( Request.post(Path.root / "auth" / "login") .withEntity(Entity.formOf[LoginRequest]), Response.ok(Entity.html).orNotFound ) val newUser = Route( Request.post(Path.root / "new_user") .withEntity(Entity.formOf[LoginRequest]), Response.status(HttpStatus.Created, Entity.html) .orElse(Response.status(HttpStatus.Ok, Entity.html)) .orNotFound ) val logout = Route( Request.post(Path.root / "auth" / "logout") .extractHeader[Authorization], Response.ok(Entity.html).orNotFound ) val assets = AssetRoute(Path.root / "assets", "src/main/resources/assets")
В Requestах мы определяем саму структуру запроса, а затем (через .extract) то, что мы будем извлекать из ответа на запрос для последующей передачи в хэндлеры. Например, на страницах входа нам нужны Cookie, чтобы понять, авторизован ли уже пользователь или нет. В запросах на создание пользователя нужна сама наша моделька LoginRequest, а для выхода из системы — токен авторизации. В ответах мы говорим о том, что мы передадим на фронт: какие коды ответа и тип самого ответа.
Стартовая страница
Давайте рассмотрим подробнее. Роут стартовой страницы:
val index = Route( Request.get(Path.root).extractHeader[Cookie], Response.ok(Entity.html) )
Это означает, что хэндлер должен реализовать функцию Cookie => String или Cookie => IO[String]:
final case class InitialHandler( middleware: JwtAuthMiddleware[IO, UserInfo] )(using Logger[IO]): private val defaultPage = html.base("План развития", html.login(None)).toString val handler: Handler = Routes.index.handleIO: (cookie: Cookie) => cookie.getToken match case Some(token) => middleware.fromToken.run(token) .map: (user, token) => html.base( "План развития", html.welcome(user.username.value, token.value) ).toString .recoverWith: case ex => defaultPage.pure[IO] case None => defaultPage.pure[IO]
Мы извлекаем из cookie токен, проверяем его через сервис авторизации, а затем формируем ответ.
Если всё хорошо, то формируется страница
html.base(title, html.welcome(username, token)), если что-то пошло не по плану — форма логина: html.base(title, html.login(None)).
html.base, html.welcome, html.login — это Scala объекты, которые автоматически генерятся из шаблонов Twirl, лежащих в папке views.
Давайте посмотрим на эти шаблоны:
base.scala.html – основной шаблон макета, включающий скрипт HTMX, общие стили и структуру навигации.
@(title: String, content: Html) <!DOCTYPE html> <html lang="ru"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <title>@title</title> <link rel="stylesheet" href="/assets/css/pdp.css" /> <link rel="icon" href="/assets/icons/favicon.ico" type="image/x-icon" /> <script src="/assets/js/pdp.js"></script> <script src="@{"https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.js"}"> </script> </head> <body> <header class="app-header"> <div class="header-container"> <a hx-get="/home" hx-target="#app" hx-swap="outerHTML" >@title</a> </div> <div class="header-divider"></div> </header> <main class="main-content"> @content </main> </body> </html>
Это представление включает входящие параметры, которые можно использовать внутри шаблона: заголовок (title) и содержимое (content). Содержимое, как было видно выше в коде (html.base(title = title, content = html.welcome(...))), это другие представления (например, html.welcome, html.login).
Также тут есть ресурсы: благодаря роуту Routes.assets мы можем использовать статические файлы из папки ресурсов. Например, CSS: /assets/css/pdp.css, который будет браться из папки src/main/resources/assets/css/pdp.css.
Самое интересно здесь:
<a hx-get="/home" hx-target="#app" hx-swap="outerHTML">@title</a>
HTMX будет посылать GET-запрос на указанный роут, а затем заменять элемент с идентификатором app на полученный по запросу ответ.
Итак, давайте перейдем к содержимому, которое будем обновлять.
Если у нас в куках уже есть сохраненный токен, то мы попадем на страницу:
html.base( "План развития", html.welcome(user.username.value, token.value) ).toString

welcome.scala.html — аутентифицированная панель управления пользователя. Этот шаблон:
отображает имя пользователя и кнопку выхода;
отображает данные залогиненого пользователя — его задачи;
включает скрипты
saveUserCookiesиclearUserCookiesдля работы с куками
@(username: String, token: String) <div id="app" class="app-container"> <iframe onload="saveUserCookies('@token')" style="display:none;"></iframe> <div id="welcomeBlock" class="welcome-container"> <div class="welcome-header"> <div class="welcome-user"> <h2>Добро пожаловать, @username!</h2> </div> <button id="logoutBtn" type="button" onclick="clearUserCookies(); htmx.process(this); this.click();" hx-post="/auth/logout" hx-target="#app" hx-swap="outerHTML" hx-headers='{"Authorization": "Bearer @token"}' class="logout-btn" > Выход </button> </div> <div class="tabs-container"> <!-- Здесь вкладки с примерами содержимого --> </div> <div id="messageBlock"></div> </div></div>
<div id="app" class="app-container"> — это как раз тот элемент, который будет обновляться по каждому запросу.
Перейдём к следующему шаблону.
login.scala.html – Форма входа с полями для имени пользователя и пароля.
На вход передается опциональный параметр — сообщение об ошибке для возможности показа пользователю, что что-то идёт не так.
@(errorMessage: Option[String]) <div id="app" class="app-container-narrow"> <h2>Вход в систему</h2> <form id="loginForm"> <div class="form-group"> <label for="loginUsername">Имя пользователя:</label> <input id="loginUsername" name="username" type="text" required /> </div> <div class="form-group"> <label for="loginPassword">Пароль:</label> <input id="loginPassword" name="password" type="password" required /> </div> <button type="button" hx-post="/auth/login" hx-target="#app" hx-swap="outerHTML" > Войти </button> </form> <div id="messageBlock"> @errorMessage.map { msg => <div class="error">@msg</div> } </div> </div>
Именно для формы логина и формы регистрации и нужна реализация FormCodec для LoginRequest: при post-запросе в параметрах передаются username и password, которые затем декодируются в LoginRequest.
Давайте посмотрим на LoginHandler, обрабатывающий роут "/auth/login":
final case class LoginHandler( authService: AuthService[IO] ): val handler: Handler = Routes.login.handleIO: (request: LoginRequest) => request.parse .map { case (username, password) => authService .login(username, password) .map: token => html.welcome(username.value, token.value).toString.some .recoverWith: case UserNotFound(_) | InvalidPassword(_) => asError() case ex => none }.fold( _ => asError(), identity ) private def asError(): IO[Option[String]] = html.login("Некорректный логин или пароль".some) .toString.some.pure[IO]
Благодаря кодеку FormCodec здесь мы получим на вход LoginRequest и, если аутентификация завершилась успешно, то в ответе мы выдадим уже знакомый шаблон входа html.welcome. Обратите внимание, что здесь мы не используем html.base, потому что нам нужна не вся страница. Нам нужно отдать только внутренний блок, на который будет заменен элемент <div id="app". Остальная страница остаётся неизменной.
В случае же возникновения ошибки мы отдаём тот же самый шаблон html.login, из которого был вызван этот хэндлер, только теперь в параметры передаём текст ошибки для отображения на фронте:

Как работать с разными кодами ответов?
Если нам нужно передавать несколько разных кодов ответов, например — 201 (при успешном создании пользователя), 200 (если вдруг нужно сообщить об ошибках во введенных регистрационных данных), и 404 (если случилась непредвиденная ошибка), то Response определяется через orElse с orNotFound в конце:
Response.status(HttpStatus.Created, Entity.html) .orElse(Response.status(HttpStatus.Ok, Entity.html)) .orNotFound
В этом случае в хендлере нужно будет вернуть результат типа Option[Either[A, B]].
Это не очень удобно, потому что нужно помнить о том, что:
HttpStatus.Created— этоSome(Right(a));HttpStatus.Ok– этоSome(Left(b));HttpStatus.NotFound— этоNone
Если же нам нужно было бы вернуть ещё один код, то тип результата хендлера — Option[Either[A, Either[B, C]]], где
Response.status(HttpStatus.Created, Entity.html) // это Some(Right(Right(c))) .orElse(Response.status(HttpStatus.Ok, Entity.html)) // это Some(Right(Left(b))) .orElse(Response.status(HttpStatus.Conflict, Entity.html)) // это Some(Left(a)) .orNotFound // случай None
Как быстро понять, к какому именно коду ответа относится Some(Right(Left(...)))?
Точка входа
Точкой входа в приложение является объект Main. Он:
создает необходимые внутренние сервисы;
компонует все маршруты с помощью
orElse;строит и запускает сервер.
object Main extends IOApp.Simple: override def run: IO[Unit] = // Читаем конфиги и создаем ресурсы для приложения val initialHandler = InitialHandler(middleware) val homeHandler = HomeHandler(middleware) val loginHandler = LoginHandler(security.auth) val logoutHandler = LogoutHandler(security.auth, middleware) val newUserHandler = NewUserHandler(security.auth) initialHandler.handler .orElse(homeHandler.handler) .orElse(RegisterHandler.handler) .orElse(loginHandler.handler) .orElse(logoutHandler.handler) .orElse(newUserHandler.handler) .orElse(assets) .orElse(Application.notFound)
Вот и всё! Исходный код доступен здесь.
Теперь можно:
запустить приложение, выполнив в консоли sbt команду
pdp/run;открыть браузер и перейти по адресу
http://localhost:8081/;вы увидите страницу входа. Используя ссылку "Зарегистрироваться" можно создать нового пользователя;
после регистрации или входа вы будете перенаправлены на welcome-страницу
Заключение

Цель статьи была в том, чтобы привлечь внимание к веб-фреймворкам на Scala. Мы успешно создаём бэкенд-приложения, этому посвящены сотни статей и книг, а дальше возникают сложности: недостаток документации, примеров использования, и интеграции. Вышеописанный пример уже стал частью документации Krop, но этого мало. Почему бы кому-нибудь не добавить в доку пример с websocket, или помочь в исправлении ошибок?!

Визуальная составляющая очень важна для pet-проектов. Pet-проекты мы делаем бесплатно в свободное от работы время, они не приносят быстрого эффекта, только долгосрочный эффект в виде повышения hard-скиллов. Поэтому очень важна внутренняя мотивация, которую мы можем повысить в частности благодаря UI, когда видим результат своих трудов.
