Get started

Yoja is a full-stack framework — backend in pure Java, frontend in JavaScript / CSS / HTML, following the React component pattern without the virtual DOM or the build step. We walk through the yoja-blueprint-kanban app — a real task-manager with a REST API, WebSocket fan-out, session-cookie auth, a background worker and an external HTTP call — file by file, from the model to the server.

Prerequisites

  • Java 25 — the Gradle toolchain is pinned to Java 25; Yoja uses records, sealed types and modern language features introduced up to Java 25.
  • Gradle 8+ — the project ships a gradlew wrapper, so you don't need Gradle installed globally.
  • A browser when using yoja-web or yoja-selenium.

What it shows

  • HTTP REST — task CRUD endpoints, JSON in / JSON out.
  • WebSocket fan-out — every mutation echoes a {event, task | id} payload on /ws/tasks.
  • Session-cookie auth — a single auth handler gating every task endpoint.
  • HttpClient against an external API — fetches a "quote of the moment" from zenquotes.io.
  • Worker thread offload — CSV export runs on a single-thread worker, not the event loop.
  • Recurring task — a Timer logs board stats every 30 seconds.
  • yoja-web frontend — kanban board with section controllers, WebSocket updates, language switch, responsive layout.
  • Two test families — pure Selenium suite and JS-module / jsUnit demos.

Project layout

yoja-blueprint-kanban/
├── src/main/java/yoja/blueprint/kanban/
│   ├── Main.java                  # router, server, session, WebSocket, timer
│   ├── api/
│   │   ├── AuthApi.java           # /api/login, /api/logout, /api/me
│   │   └── TaskApi.java           # /api/tasks/* — gated by auth handler
│   ├── model/Task.java            # immutable record
│   └── service/
│       ├── TaskService.java       # in-memory store + Timer + Worker
│       └── QuoteService.java      # calls zenquotes.io via HttpClient
└── src/main/webapp/yoja/blueprint/kanban/webapp/
    ├── index.html                 # page entry — yw-controler on <body>, sections inline
    ├── index.css                  # page-level styles
    ├── indexControler.js          # prepends header, wires storageService hook
    ├── common.css
    ├── YojaWeb.conf.js            # version, defaultLanguage, mediaDescriptions
    └── sections/
        ├── header/
        │   ├── header.html        # fragment prepended by indexControler
        │   ├── header.css
        │   ├── header.xml         # i18n translations
        │   └── HeaderControler.js
        ├── login/
        │   ├── login.css
        │   ├── login.xml          # i18n translations
        │   └── LoginControler.js
        ├── taskboard/
        │   ├── taskboard.css
        │   ├── taskboard.xml      # i18n translations
        │   └── TaskBoardControler.js
        ├── taskdetail/
        │   ├── taskdetail.html    # detail view markup
        │   ├── taskdetail.css
        │   ├── taskdetail.xml     # i18n translations
        │   └── TaskDetailControler.js
        └── taskform/
            ├── taskform.html      # form markup
            ├── taskform.css
            ├── taskform.xml       # i18n translations
            └── TaskFormControler.js

Dependencies

All modules are published under group com.easygoingapi. The example app declares:

// build.gradle
plugins {
    id 'application'
}

application {
    mainClass = 'com.easygoingapi.yoja.example.Main'
}

dependencies {
    implementation 'com.easygoingapi:yoja-core:VERSION'
    implementation 'com.easygoingapi:yoja-http-server:VERSION'
    implementation 'com.easygoingapi:yoja-http-client:VERSION'
    runtimeOnly    'com.easygoingapi:yoja-web:VERSION'      // JS/CSS/HTML assets only — no Java classes to compile against
    testImplementation 'com.easygoingapi:yoja-selenium:VERSION'
}

Why runtimeOnly for yoja-web?
yoja-web contains no Java classes — it is a jar of static JS/CSS/HTML files served by the router at runtime via the classloader. Because the Java compiler never sees any import from it, runtimeOnly is correct: the jar lands on the runtime classpath (so Class.getClassLoader().getResource(...) can find its contents) but not on the compile classpath.

yoja-core is pulled transitively by the other modules. You only need to add it explicitly if you want to control its version independently.

Step 1 — The model: Task.java

The domain model is a Java record — immutable, with a factory and a JSON serialiser used by the REST API and WebSocket events.

package com.easygoingapi.yoja.example.model;

import java.util.UUID;
import io.vertx.core.json.JsonObject;

public record Task(String id, String title, String status, String assignee) {

    // Factory: fresh UUID + initial status "todo"
    public static Task of(String title, String assignee) {
        return new Task(UUID.randomUUID().toString(), title, "todo", assignee);
    }

    // Returns a copy with a different status — immutability preserved
    public Task withStatus(String status) {
        return new Task(id, title, status, assignee);
    }

    // Serialises to the JSON shape the REST API and WebSocket events carry
    public JsonObject toJson() {
        return new JsonObject().put("id", id)
                               .put("title", title)
                               .put("status", status)
                               .put("assignee", assignee);
    }
}

Step 2 — In-memory store: TaskService.java

The service keeps tasks in a CopyOnWriteArrayList so the event loop can iterate safely while WebSocket handlers mutate from other threads. Two patterns from the framework are visible here:

  • logStats() is called from a recurring Timer — it only emits logs so it's safe to run on the event loop.
  • exportCsv() does disk I/O, so it is dispatched to Worker.singleThread. The call is keyed by "csv-export" to deduplicate concurrent triggers.
package com.easygoingapi.yoja.example.service;

import java.util.concurrent.CopyOnWriteArrayList;
import com.easygoingapi.yoja.core.worker.Worker;
import com.easygoingapi.yoja.example.model.Task;

public class TaskService {

    private final List<Task> tasks = new CopyOnWriteArrayList<>();

    public TaskService() {
        // seed with three sample tasks so the UI has something to show
        tasks.add(Task.of("Setup project structure", "Alice"));
        tasks.add(new Task(UUID.randomUUID().toString(), "Write REST API",    "in-progress", "Bob"));
        tasks.add(new Task(UUID.randomUUID().toString(), "Deploy to server", "done",        "Alice"));
    }

    public Task findById(String id) {
        return tasks.stream()
                    .filter(t -> t.id().equals(id))
                    .findFirst().orElse(null);
    }

    public Task add(String title, String assignee) {
        Task task = Task.of(title, assignee.isBlank() ? "unassigned" : assignee);
        tasks.add(task);
        return task;
    }

    public Task updateStatus(String id, String status) {
        for (int i = 0; i < tasks.size(); i++) {
            if (tasks.get(i).id().equals(id)) {
                Task updated = tasks.get(i).withStatus(status);
                tasks.set(i, updated);
                return updated;
            }
        }
        return null;
    }

    public boolean delete(String id) {
        return tasks.removeIf(t -> t.id().equals(id));
    }

    public JsonArray toJsonArray() {
        JsonArray arr = new JsonArray();
        tasks.forEach(t -> arr.add(t.toJson()));
        return arr;
    }

    // Called by Timer every 30 s — runs on the event loop, logs only
    public void logStats() {
        long todo       = tasks.stream().filter(t -> "todo"       .equals(t.status())).count();
        long inProgress = tasks.stream().filter(t -> "in-progress".equals(t.status())).count();
        long done       = tasks.stream().filter(t -> "done"       .equals(t.status())).count();
        LOGGER.info("Tasks — todo: {}, in-progress: {}, done: {}", todo, inProgress, done);
    }

    // Offloads CSV file I/O to a worker thread — never blocks the event loop
    public void exportCsv() {
        Worker.singleThread.once("csv-export", () -> {
            StringBuilder sb = new StringBuilder("id,title,status,assignee\n");
            tasks.forEach(t -> sb.append(t.id()).append(",")
                                 .append(t.title()).append(",")
                                 .append(t.status()).append(",")
                                 .append(t.assignee()).append("\n"));
            Path csvFile = Path.of("build/tasks-export.csv");
            Files.createDirectories(csvFile.getParent());
            Files.writeString(csvFile, sb.toString());
        });
    }
}

Step 3 — External HTTP call: QuoteService.java

QuoteService shows how to call an external REST API from the backend using yoja-http-client. One shared HttpEngine owns the connection pool; a fluent HttpClient is built from it and pinned to the upstream host and port. If the upstream is unavailable a built-in FALLBACK quote is returned so the UI never has an empty slot.

package com.easygoingapi.yoja.example.service;

import com.easygoingapi.yoja.http.client.*;
import io.vertx.core.Future;
import io.vertx.core.json.*;

public class QuoteService {

    public static final JsonObject FALLBACK = new JsonObject()
            .put("quote",  "The best way to predict the future is to invent it.")
            .put("author", "Alan Kay");

    private final HttpClient client;

    public QuoteService() {
        HttpEngine engine = new HttpEngine();          // owns the connection pool
        this.client = HttpClient.builder(engine)
                                .host("zenquotes.io")
                                .port(443)
                                .ssl(true)
                                .build();
    }

    // Returns a { quote, author } JSON object; falls back silently on error
    public Future<JsonObject> fetch() {
        return client.send(HttpGet.of("/api/random"))
                     .map(res -> {
                         if (res.statusCode() == 200) {
                             JsonObject item = res.bodyAsJsonArray().getJsonObject(0);
                             return new JsonObject()
                                     .put("quote",  item.getString("q"))
                                     .put("author", item.getString("a"));
                         }
                         return FALLBACK;
                     })
                     .onFailure(e -> LOGGER.error("Quote API failed", e));
    }
}

Step 4 — Auth endpoints: AuthApi.java

Three static factories each return a WebService. The login endpoint reads the body, validates the credentials, writes the user name into the session, and replies with JSON. The unauthorized helper is shared with the auth handler in Main.

package com.easygoingapi.yoja.example.api;

import com.easygoingapi.yoja.core.http.HttpMethod;
import com.easygoingapi.yoja.http.server.*;
import io.vertx.core.json.JsonObject;

public class AuthApi {

    private static final String USERNAME = "demo";
    private static final String PASSWORD = "demo";

    // POST /api/login  — { username, password } → { user } or 401
    public static WebService login() {
        return new WebService(HttpMethod.POST, "/api/login", routing -> {
            JsonObject body = routing.request().bodyAsJsonObject();
            if (USERNAME.equals(body.getString("username"))
                    && PASSWORD.equals(body.getString("password"))) {
                routing.session().put("user", USERNAME);       // store in session cookie
                routing.response().send(new JsonObject().put("user", USERNAME));
            } else {
                unauthorized(routing);
            }
        });
    }

    // GET /api/logout  — destroys the session
    public static WebService logout() {
        return new WebService(HttpMethod.GET, "/api/logout", routing -> {
            HttpSession session = routing.session();
            if (session != null) session.destroy();
            routing.response().send(new JsonObject().put("ok", true));
        });
    }

    // GET /api/me  — returns { user } or 401
    public static WebService me() {
        return new WebService(HttpMethod.GET, "/api/me", routing -> {
            HttpSession session = routing.session();
            if (session != null && session.get("user") != null) {
                routing.response().send(new JsonObject().put("user", session.get("user").toString()));
            } else {
                unauthorized(routing);
            }
        });
    }

    // Shared by login(), me() and the auth handler in Main
    public static void unauthorized(HttpRouting r) {
        r.response().statusCode(401);
        r.response().send("unauthorized");
    }
}

Step 5 — Task endpoints: TaskApi.java

Six static factories, each returning a WebService. The mutating endpoints (create, update, delete) broadcast a {event, task} JSON payload over the /ws/tasks WebSocket immediately after writing to the store, so every connected browser refreshes in real time without polling.

package com.easygoingapi.yoja.example.api;

import com.easygoingapi.yoja.core.http.HttpMethod;
import com.easygoingapi.yoja.http.server.*;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;

public class TaskApi {

    // GET /api/tasks  — full list
    public static WebService getAll(TaskService svc, Handler<HttpRouting> auth) {
        return new WebService(HttpMethod.GET, "/api/tasks", auth, r ->
            r.response().send(svc.toJsonArray()));
    }

    // POST /api/tasks  — { title, assignee } → 201 + new task + WS broadcast
    public static WebService create(TaskService svc, WebSocket ws, Handler<HttpRouting> auth) {
        return new WebService(HttpMethod.POST, "/api/tasks", auth, r -> {
            JsonObject body = r.request().bodyAsJsonObject();
            Task task = svc.add(body.getString("title", ""),
                                body.getString("assignee", ""));
            ws.send(new JsonObject().put("event", "created").put("task", task.toJson()).encode());
            r.response().statusCode(201);
            r.response().send(task.toJson());
        });
    }

    // POST /api/tasks/:id/status  — { status } → updated task + WS broadcast
    public static WebService update(TaskService svc, WebSocket ws, Handler<HttpRouting> auth) {
        return new WebService(HttpMethod.POST, "/api/tasks/:id/status", auth, r -> {
            Task updated = svc.updateStatus(r.request().firstParameter("id"),
                                            r.request().bodyAsJsonObject().getString("status"));
            if (updated == null) { r.fail(404); return; }
            ws.send(new JsonObject().put("event", "updated").put("task", updated.toJson()).encode());
            r.response().send(updated.toJson());
        });
    }

    // GET /api/tasks/:id/delete  — removes task + WS broadcast
    public static WebService delete(TaskService svc, WebSocket ws, Handler<HttpRouting> auth) {
        return new WebService(HttpMethod.GET, "/api/tasks/:id/delete", auth, r -> {
            String id = r.request().firstParameter("id");
            if (!svc.delete(id)) { r.fail(404); return; }
            ws.send(new JsonObject().put("event", "deleted").put("id", id).encode());
            r.response().send(new JsonObject().put("ok", true));
        });
    }

    // GET /api/tasks/:id  — single task or 404
    public static WebService getById(TaskService svc, Handler<HttpRouting> auth) {
        return new WebService(HttpMethod.GET, "/api/tasks/:id", auth, r -> {
            Task task = svc.findById(r.request().firstParameter("id"));
            if (task == null) { r.fail(404); return; }
            r.response().send(task.toJson());
        });
    }

    // GET /api/tasks/export  — triggers async CSV write, returns immediately
    public static WebService exportCsv(TaskService svc, Handler<HttpRouting> auth) {
        return new WebService(HttpMethod.GET, "/api/tasks/export", auth, r -> {
            svc.exportCsv();
            r.response().send(new JsonObject().put("status", "export started"));
        });
    }
}

Step 6 — Wiring it all: Main.java

Main.start() brings every piece together in one place. Reading it top to bottom you can see the full routing of the application:

  • A session store (SID cookie, 30-minute idle).
  • A recurring Timer that logs board stats every 30 seconds.
  • A single auth handler reused in front of every task endpoint.
  • Two static web apps served from jars: the Yoja-Web runtime under /yoja and the example webapp at /.
  • Auth, quote, and task routes wired in declaration order.
  • A response hook that stamps X-Powered-By: yoja on every reply.
  • Optional TLS via a self-signed certificate.
package com.easygoingapi.yoja.example;

import com.easygoingapi.yoja.core.YojaApp;
import com.easygoingapi.yoja.core.http.HttpMethod;
import com.easygoingapi.yoja.core.worker.Timer;
import com.easygoingapi.yoja.http.server.*;

public class Main {

    public static void main(String[] args) {
        int port = 8080;
        boolean ssl = false;
        for (int i = 0; i < args.length - 1; i++) {
            if ("-p"  .equals(args[i])) port = Integer.parseInt(args[i + 1]);
            if ("-ssl".equals(args[i])) ssl  = Boolean.parseBoolean(args[i + 1]);
        }
        YojaApp.start();                       // 1. boot the Vert.x runtime
        start(port, ssl)
            .onSuccess(s -> LOGGER.info("http://localhost:{} — login: demo / demo", s.port()))
            .onFailure(Throwable::printStackTrace);
    }

    public static Future<HttpServer> start(int port, boolean ssl) {

        // 2. Services
        TaskService   taskService   = new TaskService();
        QuoteService  quoteService  = new QuoteService();

        // 3. WebSocket endpoint  — broadcast task mutations to every client
        WebSocket        taskWebSocket     = new WebSocket("/ws/tasks");
        WebSocketService webSocketService  = new WebSocketService();
        webSocketService.add(taskWebSocket);

        // 4. Session store — SID cookie, 30-minute idle timeout
        HttpSessionStore httpSessionStore = new HttpSessionStore("SID", Duration.ofMinutes(30));

        // 5. Recurring timer — log board stats every 30 s on the event loop
        Timer.schedule("logs", t -> taskService.logStats())
             .period(Duration.ofSeconds(30))
             .build();

        // 6. Auth guard — reused in front of every task endpoint
        Handler<HttpRouting> authHandler = routing -> {
            if (routing.session() == null || routing.session().get("user") == null)
                AuthApi.unauthorized(routing);
            routing.nextHandler();
        };

        // 7. Router — declare everything in one fluent chain
        HttpRouter router = HttpRouter.builder()
            .session(httpSessionStore)
            .contentType("js",   "application/javascript")
            .contentType("css",  "text/css")
            .contentType("html", "text/html")
            .contentType("xml",  "application/xml")
            // static resources — served from jars on the classpath
            .webResource(WebApp.of(WebApp.Type.jar, "com.easygoingapi.yoja.web", "/yoja"), "/*")
            .webResource(WebApp.jar("com.easygoingapi.yoja.example.webapp"), "/*")
            // misc utility routes
            .webService(HttpMethod.GET, "/favicon.ico", r -> r.response())
            .webService(HttpMethod.GET, "/",            r -> r.redirect("/index.html"))
            // auth API
            .webService(AuthApi.login())
            .webService(AuthApi.logout())
            .webService(AuthApi.me())
            // quote from external API (no auth required)
            .webService(new WebService(HttpMethod.GET, "/api/quote", routing ->
                quoteService.fetch()
                            .onSuccess(json -> routing.response().send(json))
                            .onFailure(e    -> routing.response().send(QuoteService.FALLBACK))))
            // task API — all gated by the auth handler
            .webService(TaskApi.getAll(taskService, authHandler))
            .webService(TaskApi.create(taskService, taskWebSocket, authHandler))
            .webService(TaskApi.update(taskService, taskWebSocket, authHandler))
            .webService(TaskApi.delete(taskService, taskWebSocket, authHandler))
            .webService(TaskApi.exportCsv(taskService, authHandler))
            .webService(TaskApi.getById(taskService, authHandler))
            // cross-cutting response hook — stamp every reply
            .onResponse(event -> event.putHeader("X-Powered-By", "yoja"))
            .build();

        // 8. Server
        HttpServer.Builder builder = HttpServer.builder(router, port)
                                               .webSocketService(webSocketService);
        if (ssl) builder.sslSelfSigned();
        return builder.start();
    }
}

Frontend tour

Each section of the page has its own ES6 controller class, exported as the module's default export. The runtime instantiates it once and injects the section handle. Controllers communicate through eventService — they never import each other directly.

index.html
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title>Yoja Kanban</title>
    <script type="module" src="/yoja/YojaWeb-1.0.0.js"></script>
</head>
<!-- yw-controler on <body> — the page-level entry point -->
<body yw-controler="./indexControler.js" style="display:none">

    <!-- Header injected dynamically by indexControler via yojaWeb.prepend -->

    <!-- Login: yw-css loads section styles, yw-language loads the i18n file -->
    <section id="login-section"
             yw-controler="./sections/login/LoginControler.js"
             yw-css="./sections/login/login.css"
             yw-language="./sections/login/login.xml">
        <h2 yw-i18n="login.title"></h2>
        <form>
            <input type="text"     name="username" yw-i18n-placeholder="login.user"/>
            <input type="password" name="password" yw-i18n-placeholder="login.pass"/>
            <button type="submit" yw-i18n="login.submit"></button>
        </form>
        <p class="error-msg"></p>
    </section>

    <!-- Task board: kanban columns, cards appended by the controller -->
    <section id="taskboard-section"
             yw-controler="./sections/taskboard/TaskBoardControler.js"
             yw-css="./sections/taskboard/taskboard.css"
             yw-language="./sections/taskboard/taskboard.xml">
        <div class="quote-bar"></div>
        <div class="columns">
            <div class="column" data-status="todo">
                <h3 yw-i18n="col.todo"></h3>
                <div class="cards"></div>
            </div>
            <div class="column" data-status="in-progress">
                <h3 yw-i18n="col.inprogress"></h3>
                <div class="cards"></div>
            </div>
            <div class="column" data-status="done">
                <h3 yw-i18n="col.done"></h3>
                <div class="cards"></div>
            </div>
        </div>
    </section>

    <!-- Task detail: HTML loaded via yw-include (separate file) -->
    <div yw-include="./sections/taskdetail/taskdetail.html"></div>

    <!-- Task form: markup loaded via yw-slot, controller and styles declared here -->
    <section id="taskform-section"
             yw-controler="./sections/taskform/TaskFormControler.js"
             yw-css="./sections/taskform/taskform.css"
             yw-language="./sections/taskform/taskform.xml"
             yw-slot="./sections/taskform/taskform.html"></section>

</body>
</html>

yw-controler — declares the ES6 module whose default export is the controller class.
yw-css — scoped stylesheet loaded and applied to the section.
yw-language — XML i18n file; yw-i18n / yw-i18n-placeholder bind keys to DOM nodes.
yw-include — fetches an HTML fragment and injects it as children.
yw-slot — same as yw-include but the markup is loaded into the element, alongside its controller.

Step 7 — Page entry point: indexControler.js

Prepends the shared header fragment into <body> on onDocumentReady, then reveals the page once the fragment has resolved (hides the FOUC while sections wire up). Also demonstrates the storageService "set" hook: every write to the login-date key is logged to the console.

yojaWeb.onDocumentReady(() => {
    yojaWeb.prepend(yojaWeb.firstTag('body'),
                    import.meta.resolve(yojaWeb.path('./sections/header/header.html')))
           .then(() => yojaWeb.firstTag('body').style.display = 'block');
});

export default class IndexControler {
    constructor(section) {
        const storage = yojaWebApi.storageService;
        storage.on('local', 'set', 'login-date', date => {
            const previous = storage.getLocalItem('login-date');
            if (previous) console.log('last time connected:', previous);
            console.log('login date:', date);
        });
    }
}

Step 8 — Shared header: HeaderControler.js

Wires the language-switch buttons, shows/hides the logout button on user:logged-in / user:logged-out events, and mirrors the active breakpoint into section.tag.dataset.media so CSS can target [data-media="mobile"] etc.

export default class HeaderControler {
    constructor(section) {
        const lang = yojaWebApi.languageService;
        const events = yojaWebApi.eventService;
        const http = yojaWebApi.httpClient;
        const responsive = yojaWebApi.responsiveService;

        // Language switch
        section.findTags('.btn-lang').forEach(btn => {
            btn.addEventListener('click', () => lang.setLanguage(btn.dataset.lang));
        });

        // Logout button — hidden until login event fires
        const btnLogout = section.firstTag('.btn-logout');
        btnLogout.style.display = 'none';
        events.on('user:logged-in',  () => { btnLogout.style.display = 'inline-block'; });
        events.on('user:logged-out', () => { btnLogout.style.display = 'none'; });
        btnLogout.addEventListener('click', () => {
            http.get({ url: '/api/logout' }).then(() => events.trigger('user:logged-out'));
        });

        // Responsive marker on the root element
        responsive.onMedia(media => { section.tag.dataset.media = media; });
    }
}

Step 9 — Login form: LoginControler.js

Uses a top-level await to load the i18n translator before the class is exported. The submit handler POSTs credentials; on 200 it hides itself, broadcasts user:logged-in and stamps login-date in localStorage. A section.pageReady hook silently auto-logs-in if an existing session is found.

// top-level await — translator is ready before any constructor runs
const lang = yojaWebApi.languageService;
const translator = await lang.loadTranslator(
    import.meta.resolve(yojaWeb.path('./login.xml')));

export default class LoginControler {
    constructor(section) {
        const http = yojaWebApi.httpClient;
        const events = yojaWebApi.eventService;
        const form = section.firstTag('form');
        const error = section.firstTag('.error-msg');

        form.addEventListener('submit', e => {
            e.preventDefault();
            const username = section.firstTag('[name=username]').value;
            const password = section.firstTag('[name=password]').value;
            http.post({ url: '/api/login' }, { username, password }).then(res => {
                if (res.status === 200) {
                    section.tag.style.display = 'none';
                    events.trigger('user:logged-in', res.body);
                    yojaWebApi.storageService.setLocalItem('login-date', new Date());
                } else {
                    error.textContent = translator('login.error') ?? 'Invalid credentials';
                }
            });
        });

        // Reset form on logout
        events.on('user:logged-out', () => {
            section.tag.style.display = '';
            section.findTags('input').forEach(i => (i.value = ''));
            error.textContent = '';
        });

        // Auto-login if session already exists
        section.pageReady(() => {
            http.get({ url: '/api/me' }).then(check => {
                if (check.status === 200) {
                    section.tag.style.display = 'none';
                    events.trigger('user:logged-in', check.body);
                } else {
                    events.trigger('user:logged-out');
                }
            });
        });
    }
}

Step 10 — Kanban board: TaskBoardControler.js

Hidden by default; revealed on user:logged-in after loading tasks and the "quote of the moment". Opens /ws/tasks and applies created / updated / deleted events incrementally so every connected browser stays in sync without polling.

const STATUS_NEXT = { 'todo': 'in-progress', 'in-progress': 'done' };

export default class TaskBoardControler {
    #section; #http; #ws; #events; #params;

    constructor(section) {
        this.#section = section;
        this.#http    = yojaWebApi.httpClient;
        this.#ws      = yojaWebApi.webSocketService;
        this.#events  = yojaWebApi.eventService;
        this.#params  = yojaWebApi.urlParameterService;

        this.hide();
        this.#events.on('user:logged-in', () => {
            this.#loadTasks().then(() => this.show())
                             .then(() => this.#connectWebSocket());
            this.#loadQuote();
        });
        this.#events.on('user:logged-out', () => this.hide());
    }

    hide() { this.#section.tag.style.display = 'none'; }
    show() { this.#section.tag.style.display = 'block'; }

    #loadQuote() {
        return this.#http.get({ url: '/api/quote' }).then(res => {
            if (res.status === 200) {
                const bar = this.#section.firstTag('.quote-bar');
                bar.textContent = `"${res.body.quote}" — ${res.body.author}`;
                bar.style.display = 'block';
            }
        });
    }

    #loadTasks() {
        return this.#http.get({ url: '/api/tasks' }).then(res => {
            if (res.status === 200) {
                this.#section.findTags('.cards').forEach(col => (col.innerHTML = ''));
                res.body.forEach(task => this.#addCard(task));
            }
        });
    }

    #addCard(task) {
        const col = this.#section.firstTag(`.column[data-status="${task.status}"] .cards`);
        if (!col) return;
        const card = document.createElement('div');
        card.className = 'card';
        card.dataset.id = task.id;
        card.innerHTML = `
            <span class="task-title">${task.title}</span>
            <span class="task-assignee">${task.assignee}</span>
            <div class="card-actions">
                <button class="btn-detail">···</button>
                ${STATUS_NEXT[task.status] ? '<button class="btn-next">→</button>' : ''}
                <button class="btn-delete">✕</button>
            </div>`;
        card.querySelector('.btn-detail').addEventListener('click', () => {
            this.#params.set('task', task.id); this.#params.push();
        });
        card.querySelector('.btn-delete')?.addEventListener('click', () =>
            this.#http.get({ url: `/api/tasks/${task.id}/delete` }));
        card.querySelector('.btn-next')?.addEventListener('click', () =>
            this.#http.post({ url: `/api/tasks/${task.id}/status` },
                            { status: STATUS_NEXT[task.status] }));
        col.appendChild(card);
    }

    #connectWebSocket() {
        const socket = this.#ws.webSocket('/ws/tasks');
        socket.onMessage(msg => {
            const data = JSON.parse(msg.data);
            if (data.event === 'created') {
                this.#addCard(data.task);
            } else if (data.event === 'updated') {
                this.#section.firstTag(`.card[data-id="${data.task.id}"]`)?.remove();
                this.#addCard(data.task);
            } else if (data.event === 'deleted') {
                this.#section.firstTag(`.card[data-id="${data.id}"]`)?.remove();
            }
        });
    }
}

Step 11 — Task detail: TaskDetailControler.js

Visibility driven by the ?task=<id> URL parameter. show() hides every sibling controller that exposes a hide() method; hide() restores them — making the back button work without any direct coupling between controllers.

export default class TaskDetailControler {
    #section; #http; #params; #events;

    constructor(section) {
        this.#section = section;
        this.#http    = yojaWebApi.httpClient;
        this.#params  = yojaWebApi.urlParameterService;
        this.#events  = yojaWebApi.eventService;

        this.hide();

        this.#params.onChange(handler => {
            if (handler.event === 'after-push' || handler.event === 'pop')
                this.#onParamChange();
        });

        section.firstTag('.btn-back').addEventListener('click', () => {
            this.#params.remove('task'); this.#params.push();
        });
        this.#events.on('user:logged-out', () => {
            this.#params.remove('task');
            this.#section.tag.style.display = 'none';
        });
        section.pageReady(() => {
            if (this.#params.has('task')) this.#onParamChange();
        });
    }

    hide() {
        this.#section.tag.style.display = 'none';
        yojaWeb.controlerService.find(document, c => c.show && c !== this)
               .forEach(c => c.show());
    }

    show() {
        this.#section.tag.style.display = 'block';
        yojaWeb.controlerService.find(document, c => c.hide && c !== this)
               .forEach(c => c.hide());
    }

    #onParamChange() {
        const taskId = this.#params.get('task');
        if (!taskId) { this.hide(); return; }
        this.#http.get({ url: `/api/tasks/${taskId}` }).then(res => {
            if (res.status === 200) {
                this.#section.firstTag('.detail-title').textContent    = res.body.title;
                this.#section.firstTag('.detail-assignee').textContent = res.body.assignee;
                this.#section.firstTag('.detail-status').textContent   = res.body.status;
                this.#section.firstTag('.detail-id').textContent       = res.body.id;
                this.show();
            } else {
                this.#params.remove('task'); this.#params.replace(); this.hide();
            }
        });
    }
}

Step 12 — Task form: TaskFormControler.js

Shown on user:logged-in, hidden on logout. The submit handler POSTs {title, assignee} to /api/tasks — the WebSocket fan-out in TaskBoardControler handles rendering the new card. The export button triggers the backend's CSV worker and logs the status response.

export default class TaskFormControler {
    #section;

    constructor(section) {
        this.#section = section;
        const http = yojaWebApi.httpClient;
        const events = yojaWebApi.eventService;

        this.hide();
        events.on('user:logged-in',  () => this.show());
        events.on('user:logged-out', () => this.hide());

        section.firstTag('form').addEventListener('submit', e => {
            e.preventDefault();
            const title    = section.firstTag('[name=title]').value.trim();
            const assignee = section.firstTag('[name=assignee]').value.trim();
            if (!title) return;
            http.post({ url: '/api/tasks' }, { title, assignee })
                .then(() => section.firstTag('form').reset());
        });

        // CSV export — kicks off a Worker thread on the backend
        section.firstTag('.btn-export').addEventListener('click', () => {
            http.get({ url: '/api/tasks/export' })
                .then(res => console.info('Export:', res.body?.status));
        });
    }

    hide() { this.#section.tag.style.display = 'none'; }
    show() { this.#section.tag.style.display = 'block'; }
}

Tests

Step 13 — End-to-end Selenium suite: TaskAppTest.java

Nine ordered tests covering auth, task CRUD and the detail view. One HttpServer is shared across the whole class; each @Test gets a fresh Chrome session. Uses SeleniumService directly — no TestBuilder. repeatScript polls a JS snippet until it returns truthy, providing a reliable wait without Thread.sleep.

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class TaskAppTest {

    private static final int PORT = 9090;
    private static HttpServer server;
    private SeleniumService selenium;

    @BeforeAll static void startApp() {
        server = awaitValue(Main.start(PORT, false));
    }
    @AfterAll  static void stopApp()  { if (server != null) await(server.stop()); }

    @BeforeEach void setUp() {
        selenium = SeleniumService.newInstance(
            Browser.builder(Browser.CHROME).mode(Browser.Mode.HEADFUL).build());
    }
    @AfterEach  void tearDown() { if (selenium != null) selenium.close(); }

    @Test @Order(1)
    void loginPageIsDisplayed() {
        selenium.getHttpPage(appUrl("/index.html"));
        assertNotNull(selenium.firstTag("form"));
    }

    @Test @Order(2)
    void loginWithInvalidCredentials() {
        selenium.getHttpPage(appUrl("/index.html"));
        selenium.firstTag("[name=username]").sendKeys("demo");
        selenium.firstTag("[name=password]").sendKeys("wrong-password");
        selenium.firstTag("[type=submit]").click();
        String errorText = selenium.repeatScript(Duration.ofSeconds(10), """
            const el = yojaWeb.firstTag('.error-msg')
            return el && el.textContent.trim().length > 0 ? el.textContent : null
        """);
        assertFalse(errorText.isBlank());
    }

    @Test @Order(3)
    void loginWithValidCredentials() {
        login();
        assertTrue(selenium.findTags(".card").size() > 0);
    }

    @Test @Order(5)
    void addTaskAppearsOnBoard() {
        login();
        selenium.firstTag("[name=title]").sendKeys("Test task from Selenium");
        selenium.firstTagFrom(selenium.firstTag("#taskform-section"), "[type=submit]").click();
        selenium.repeatScript(Duration.ofSeconds(10), """
            return yojaWeb.findTags('.task-title')
                          .some(t => t.textContent.includes('Test task from Selenium')) ? true : null
        """);
        long count = selenium.findTags(".task-title").stream()
                             .filter(el -> el.getText().contains("Test task from Selenium"))
                             .count();
        assertEquals(1, count);
    }

    // ... 5 more tests: logoutHidesTaskBoard, moveTaskToNextStatus,
    //     deleteTask, openTaskDetail, taskDetailBackButtonReturnsToBoard

    private void login() {
        selenium.getHttpPage(appUrl("/index.html"));
        selenium.firstTag("[name=username]").sendKeys("demo");
        selenium.firstTag("[name=password]").sendKeys("demo");
        selenium.firstTag("[type=submit]").click();
        selenium.repeatScript(Duration.ofSeconds(10), "return yojaWeb.findTags('.card').length > 0 ? true : null");
    }
}

Step 14 — JS-unit tests: JsUnitDemoTest.java

testJsUnit imports a JS module and runs each named export as its own independent test step. A failure in one function does not skip the others. ywAssert is injected via .loadYwAssert() before the module runs.

JsUnitDemoTest.java
public class JsUnitDemoTest {

    @BeforeAll static void startApp() { server = awaitValue(Main.start(9091, false)); }
    @AfterAll  static void stopApp()  { if (server != null) await(server.stop()); }

    @TestFactory
    Stream<DynamicNode> jsUnitDemo() {
        return TestBuilder.builder()
            .browser(Browser.builder(Browser.CHROME)
                            .mode(Browser.Mode.HEADLESS).build())
            .test("open login page",
                  ctx -> ctx.seleniumService().getHttpPage(INDEX_URL))
            .loadYwAssert()
            // each named export = one step
            .testJsUnit("/jsUnitSyncTest.js",
                        List.of("titleIsTaskManager", "loginFormIsPresent"))
            .stream();
    }
}
jsUnitSyncTest.js
// Each named export = one independent test step.
// Throw to fail; return to pass.

export function titleIsTaskManager() {
    ywAssert.assertEquals('Yoja Kanban',
                          document.title,
                          'document title')
}

export function loginFormIsPresent() {
    ywAssert.assertNotNull(yojaWeb.firstTag('form'),
                           'login form should exist')
}

Step 15 — JS-module tests: TestModuleDemoTest.java

testModule imports a JS module and calls its single default export once — the whole file is one test step. Use this when you want a self-contained scenario rather than a set of independent assertions.

TestModuleDemoTest.java
public class TestModuleDemoTest {

    @BeforeAll static void startApp() { server = awaitValue(Main.start(9092, false)); }
    @AfterAll  static void stopApp()  { if (server != null) await(server.stop()); }

    @TestFactory
    Stream<DynamicNode> testModuleDemo() {
        return TestBuilder.builder()
            .browser(Browser.builder(Browser.CHROME)
                            .mode(Browser.Mode.HEADLESS).build())
            .test("open login page",
                  ctx -> ctx.seleniumService().getHttpPage(INDEX_URL))
            .loadYwAssert()
            // default export = one step
            .testModule("/moduleSyncTest.js")
            .stream();
    }
}
moduleSyncTest.js
// Single default export = one test step.
// Throw to fail; return to pass.

export default function() {
    ywAssert.assertEquals('Yoja Kanban',
                          document.title,
                          'document title')
    ywAssert.assertNotNull(yojaWeb.firstTag('form'),
                           'login form should be present')
}

Run it

Clone and run

$ git clone https://github.com/Easy-API-Style/yoja-blueprint-kanban.git
$ cd yoja-blueprint-kanban
$ ./gradlew run

Custom port or TLS

$ ./gradlew run --args="-p 9090"
$ ./gradlew run --args="-p 8443 -ssl true"

Try it

# Check the server is up
$ curl http://localhost:8080/api/me
unauthorized

# Login
$ curl -c cookies.txt -X POST http://localhost:8080/api/login \
       -H "Content-Type: application/json" \
       -d '{"username":"demo","password":"demo"}'
{"user":"demo"}

# List tasks (session cookie sent automatically)
$ curl -b cookies.txt http://localhost:8080/api/tasks

# Create a task
$ curl -b cookies.txt -X POST http://localhost:8080/api/tasks \
       -H "Content-Type: application/json" \
       -d '{"title":"Read the docs","assignee":"you"}'

Open http://localhost:8080 in a browser to see the full Yoja-Web frontend. Log in with demo / demo. Open a second tab — task mutations broadcast over WebSocket and both tabs stay in sync instantly.

Where to go next

  • yoja-http-server — full router API: sessions, WebSocket, static resources, response hooks.
  • yoja-http-client — GET/POST builders, JSON body handling, WebSocket dialer.
  • yoja-web — the frontend framework. Section components, routing, i18n, all in native ES6.
  • yoja-coreWorker, Timer, FutureUtil and the HTTP primitive types.
  • yoja-selenium — write browser tests that boot the real app and run JS modules end-to-end.
  • yoja-blueprint-kanban — the complete source of this app. Clone it, run it, read it.