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
gradlewwrapper, so you don't need Gradle installed globally. - A browser when using
yoja-weboryoja-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
Timerlogs 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 recurringTimer— it only emits logs so it's safe to run on the event loop. -
exportCsv()does disk I/O, so it is dispatched toWorker.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 (
SIDcookie, 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
/yojaand the example webapp at/. - Auth, quote, and task routes wired in declaration order.
- A response hook that stamps
X-Powered-By: yojaon 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.
<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.
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();
}
}
// 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.
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();
}
}
// 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-core
—
Worker,Timer,FutureUtiland 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.