Instructions for AI coding agents. Humans should start at README.md and docs/.
ZeroZ Stack is an experimental pure-Java full-stack framework at version 0.10.0. The Java UI is compiled by TeaVM to run in the browser, client and server talk over a binary WebSocket RPC protocol, and the server persists a live object graph with EclipseStore. You write no JavaScript, JSON, REST routes or SQL.
Compilation target: the build produces JavaScript via TeaVM's JavaScript backend
(<targetType>JAVASCRIPT</targetType>); no module sets WEBASSEMBLY. This is a deliberate interim
choice — TeaVM's WasmGC backend does not yet provide functionality ZeroZ Stack needs — and WasmGC remains
the intended destination. Do not tell users their code compiles to Wasm today, and do not "fix" the
JavaScript target.
The client module is zerozstack-client. It was renamed from zerozstack-client-wasm so the name does not
encode a compilation backend; anything published earlier, and older documentation, uses the old
artifactId.
Requires JDK 21 and Maven 3.9+.
mvn clean install -DskipTests # from the repository root
mvn test # then testsPrefer clean. Each example server copies its dependencies into target/libs; since 0.4.1
that directory is emptied before it is refilled, so a plain install no longer leaves jars from
previous versions behind (which used to kill startup with "WELD-001409: Ambiguous dependencies").
clean remains the honest choice before a release, and the purge tolerates a jar held open by a
running server rather than failing the build.
Install before you test. The annotation processor and shared API must be in your local repository
before the modules that consume them can compile, so mvn clean test on its own is not a reliable
entry point.
A full clean build of the reactor, including every example, takes about a minute on a warm Maven
cache. Compiling the twelve example clients for the browser is roughly 45 seconds of that — the
single largest item, and it is paid on every build of the reactor because the compiler has no
usable incremental mode. There is no flag here that skips it; if you are only changing server-side
code, build the modules you touched with -pl <module> -am rather than the whole reactor.
A project generated from the archetype builds its browser bundle at prepare-package, so
mvn compile and mvn test-compile are javac and nothing else, and it builds that bundle
unoptimized and unminified by default. -Pproduction is what turns on whole-program
optimization and minification. That is the split an assistant working in a generated application
has to understand: the quick check does not compile the user interface at all, a full
mvn install does, and only -Pproduction produces the shape a user receives.
This repository's own examples do not have that split. They compile at process-classes
unminified, and that is deliberate: the browser proof page and the release gate read names out of
the generated JavaScript. It does mean an ordinary build here never reproduces a minification
bug, which is exactly how the connection bar below shipped broken twice. The one way to see the
shape users receive is routing-tour's client, which alone takes -Pproduction, and
tools/navigation-proof runs its browser test against that build as well as the ordinary one.
| Module | Contains |
|---|---|
zerozstack-shared-api |
Annotations, signals, EventTopic, validation, serialization primitives. Compiled into both tiers. |
zerozstack-apt |
Annotation processor. Generates _Serializer, _Rules, _Live, _Stub and the SPI registrar. |
zerozstack-client |
TeaVM client runtime: Zeroz4jClient, WasmRmiClient, ServerEvents. Renamed from zerozstack-client-wasm. |
zerozstack-ui-components |
Java component library styled with Tailwind/DaisyUI. Components wrap a TeaVM HTMLElement, reachable via getElement(); there is no server-side DOM state. |
zerozstack-bom |
Dependency BOM — the intended way for applications to import versions. |
zerozstack-server-core |
CDI engine, RMI dispatcher, SyncEngine, EventPublisher, dev auth. Carries no JAX-RS or servlet type, which is what makes it safe inside somebody else's WAR. |
zerozstack-server-test |
Test harness. TestServer starts an isolated server in-process, TestConnection stands in for a browser. Application takes it with test scope; it starts a bean container and must never reach a production classpath. See docs/guides/testing.md. |
zerozstack-server-jaxrs |
The JAX-RS catch-all at / serving the client bundle and the shell. A standalone server wants it; a WAR with its own servlets must be able to leave it out, which is why it is separate. |
zerozstack-server-jakarta |
Servlet-container binding: WebSocket registration, serializer bootstrap, the container ManagedThreadFactory, and an optional shell servlet. Take this instead of -helidon for a WAR. |
zerozstack-server-helidon |
Helidon HTTP/WebSocket bindings. Depends on -jaxrs. |
zerozstack-auth-oidc |
Optional OIDC authentication provider — token verification at the handshake, Keycloak claim mapping. |
zerozstack-store-eclipsestore |
Persistence on ZeroZ DB: per-tenant stores, transactions, and the embedded/server mode switch. |
zerozstack-archetype |
Maven archetype scaffolding a three-module application. |
zerozstack-examples |
Twelve runnable reference applications. |
An application has three modules: shared (@DataModel classes, @RmiService interfaces),
client (the UI, compiled for the browser by TeaVM), server (@ApplicationScoped
implementations).
- Every type crossing the wire is
@DataModel. Three shapes qualify, and nothing else: a class with a public no-arg constructor plus getters and setters; a record (0.8.0+), which needs none of that ceremony; and a sealed interface or sealed abstract class (0.8.0+), which declares "one of these types" and carries no fields of its own unless it is an abstract class. Without the annotation, serialization throws at runtime. A record may not be@LiveSyncor@ClientWritable— those edit an object in place and a record never changes — and a record may not be part of a reference cycle; the send is refused with an explanation, so use a class where the loop closes. See docs/PROTOCOL.md. - Do not wrap client event handlers in your own thread.
Component.addDomEventListeneralready wraps every DOM listener viaComponent.threaded(...), so a handler body runs on a suspendable TeaVM green thread — which is why a suspending RMI call works directly inside it. An extra thread is redundant, and updates made from one may not repaint until the next UI event. For delayed work useWindow.setTimeout. - Client-side validation is feedback, never a boundary. The server re-validates independently.
- Client writes are denied by default. Opt in with
Signals.sharedWritableor@ClientWritable. - Dispose what you create.
Effect.createandServerEvents.onreturn aDisposable;Computedhasdispose(). - Never mutate a signal's value in place.
set()skips notification when the new valueequalsthe old one, so mutate-and-set-back changes nothing. Useupdate()and return a new instance. - Bootstrap the client with
Zeroz4jClient.connect(url, onReady). Do not callBinaryPackableRegistrarorWasmRmiClient.initializeyourself — registrars are discovered viaMETA-INF/services. - Get an RMI stub with
new MyService_Stub(). There is noWasmRmiClient.create(Class). - Never empty an element by hand.
getElement().setInnerHTML("")takes what was inside off the page without telling it, so itsonDetachnever runs and its timers, effects and subscriptions keep running against a screen nobody is looking at. Swap contents withcontainer.replaceContents(next), orComponent.replaceContents(element, next)for a plain element such as an application's root<div>; empty one withremoveAll(). Both runonDetachon everything leaving, nested parts included.DetachContractTestreads every Java file in the checkout on every build and fails it otherwise. Only components put in withadd(...)get a lifecycle - appending an element straight togetElement()does not. - Never put a click listener on a
Div. A control has to be reachable with Tab and pressed with Enter, and has to have words that say what it does. Use aButton-btn-ghostandbtn-linkmake one look like anything.KeyboardAndNamingContractTestreads every component on every build and fails it otherwise, and every control also has to appear on the browser proof page intools/ui-proof. Full rule: docs/guides/ui-keyboard-and-naming.md. - Name every input with
withLabel(...), not with the constructor argument (0.8.0+).new TextField("Email address")sets the placeholder — example text inside the empty box that disappears the moment somebody types and is announced by nothing. The caption isnew TextField().withLabel("Email address").setHelperText,setRequiredIndicatorVisibleandsetErrorMessagecarry the other three things a field says, and aBindersets the last of them for you. Every input in the library has all four. - Ask for a text size by name; never write out your own (0.8.0+). Five in
com.zeroz4j.ui.theme.TextStyle-PAGE_TITLE,SECTION_TITLE,BODY,SECONDARY,CAPTION- used asTextStyle.SECONDARY.paragraph("..."),TextStyle.CAPTION.span("...")orTextStyle.CAPTION.applyTo(existingComponent). How loud is a separate question,com.zeroz4j.ui.theme.Emphasis-FULL,QUIET,FAINT- passed as a second argument only where the text disagrees with its size, such as an error line that is small but must be read. Never writetext-sm text-base-content/60or any hand-picked fade: that names a color, and it goes wrong on a tinted notice or a dark page. Text drawn inside a chart has its own four names,com.zeroz4j.ui.chart.PlotText-FIGURE,LABEL,CAPTION,MESSAGE- because class names do not reach a drawing; a test fails the build if a chart draws text without naming a role.
Persistence runs on ZeroZ DB (com.zeroz4j:zerozdb),
the sibling project in the ZeroZ4J family. Inject ZeroZDbNode, not EmbeddedStorageManager.
Where the data lives is a deployment choice, set by zeroz4j.store.mode:
| mode | who owns the data |
|---|---|
EMBEDDED (default) |
this process, no socket, one copy of the graph |
AUTO_SERVER |
this process if the store is free, otherwise whoever already owns it |
CLIENT |
a separate zerozdb server, configured with zeroz4j.store.server.host and .port |
Every write is a transaction: everything it enlists commits atomically, and if it throws, nothing is persisted and the objects it touched are restored in memory.
For code that must run in every mode, send a command — it executes wherever the data is:
@Inject ZeroZDbNode db;
long id = db.execute(new AddProduct("Laptop stand"));
int total = db.query(new CountProducts());public class AddProduct implements DbCommand<Long> {
public String name;
public AddProduct() { } // needed for deserialisation
public AddProduct(String name) { this.name = name; }
@Override
public Long execute(WriteContext ctx, Object root) {
Catalog catalog = (Catalog) root;
ctx.edit(catalog); // the counter is about to change
ctx.edit(catalog.getProducts()); // and so is the list
long id = catalog.getNextId();
catalog.setNextId(id + 1);
catalog.getProducts().add(new Product(id, name));
return id; // both land in ONE commit
}
}For code that only ever runs embedded — which is most example and single-instance code — a write-block is shorter and equivalent:
db.localDb().write(ctx -> {
ctx.edit(root.getTasks());
root.getTasks().add(task);
});localDb() is the in-process engine and is null in CLIENT mode, so using it pins that code to
EMBEDDED or AUTO_SERVER. That is a fine trade for an example and the wrong one for a library.
The producer for ZeroZDbNode is @Dependent and cannot be anything else: ZeroZDbNode is
final, a normal scope would make CDI proxy it, and a proxy is a generated subclass. Through
0.4.1 the producer was @RequestScoped and @Inject ZeroZDbNode therefore failed at deployment
with WELD-001410 in every application. Do not "fix" the scope back.
The consequence to know: a @Dependent producer runs when the injecting bean is created, so an
@ApplicationScoped service resolves its tenant once. Correct for a single-tenant application.
Wrong for one with a TenantResolver — there, inject TenantStorageProvider and call
getNode(tenantId) per operation.
Enlisting does not cascade. ctx.store(obj) covers that object only. Changing a field on the
root and a collection hanging off it means enlisting both. Getting this wrong loses the change
silently — it is in memory and never reaches disk.
Enlist before mutating with ctx.edit(obj). The rollback snapshot is taken at enlistment, so
one taken afterwards already contains the change.
DbCommand and DbQuery must be plain classes, never records. EclipseStore's serializer
reaches fields directly and the JVM refuses that for records without
--add-exports java.base/jdk.internal.misc=ALL-UNNAMED. It fails at the first remote call rather
than at compile time, so it looks like a networking fault. Public fields, public no-arg constructor.
A query returns values, not live graph nodes. Whatever it returns is serialized to the caller, so returning a deeply-connected entity ships its reachable graph. Copy or reduce first.
Never start a write inside a read block. A read lock cannot be upgraded; the engine throws rather than deadlocking.
try (var local = db.<Catalog>localReads()) {
int count = local.read(c -> c.getProducts().size()); // heap access, no round trip
}The live graph when this process owns the store, a continuously refreshed replica when it does not
— trailing the owner by about one round trip. Use db.query(...) when a read must be exactly
current — deciding whether a caller may do something, especially.
Full detail: docs/store-modes.md, docs/guides/persistence.md, and the ZeroZ DB guide.
This is the decision developers most often get wrong. Ask in order:
1. Does anything outside this browser tab need to know?
No -> local ValueSignal / Computed / Effect. Stop. (Most UI state lands here.)
2. Does the client need an answer, or is it invoking a named operation
("approve", "checkout", "log in")?
Yes -> RMI call on an @RmiService. Stop.
3. Does the data belong to somebody rather than to everybody?
Yes -> scope it, then continue to 4 to pick the mechanism.
Events: publishToUser / publishToSession / publishToClient.
LiveSync: notifyChanged(obj, Scope.X, target).
Signals: Signals.scoped(name, initial, Scope.X) -- NOT Signals.shared,
which is one value for the whole JVM by definition.
The unscoped forms reach every session with no principal check.
4. Would keeping only the latest value lose information?
Yes -> EventTopic (a discrete occurrence: a chat message, a log line).
No -> continue.
5. One value replaced wholesale, or an identified object edited field by field?
One value -> Signals.shared / sharedWritable
Identified object -> @LiveSync (+ @ClientWritable for client edits)
Two rules of thumb: state edits sync, operations call, and events are news, signals are facts — news is missed if you were not listening, facts are true when you arrive.
Full guide: docs/decide/.
| Scope | Keyed by | Needs a login? | Survives reconnect? |
|---|---|---|---|
SESSION |
WebSocket session id | no | no — the id changes on every drop |
CLIENT |
server-issued browser id | no | yes, and page reloads too |
USER |
authenticated user name | yes | yes |
TENANT |
authenticated tenant | yes | yes |
Scope.CLIENT is the default for an application with no login. CLIENT and SESSION identify a
browser, not a person — never use them to keep one user's data from another; that needs USER or
TENANT.
On the server name the target explicitly, and take it from the connection, never from an argument:
BasketSignals.BASKET.forTarget(RmiRequestContext.getClientId()).set(updated); // server
Effect.create(() -> label.setText(BasketSignals.BASKET.mine().get().size())); // clientforTarget throws on a client and mine() throws on the server — deliberately, so a browser cannot
name someone else's target. See docs/SIGNALS.md.
@Route on a class implementing RouteView<T>; the annotation processor generates the route table
at compile time, so there is no reflection and a malformed route is a compile error.
@Route(value = "/tasks/:id", layout = AppShell.class)
public class TaskDetailView implements RouteView<Task> {
public Task load(RouteParams p) { return tasks.byId(p.getLong("id")); } // runs first
public Component render(Task task, RouteParams p) { ... } // then this
}loadcompletes beforerenderis called. Never fetch from inside a mounted component.- Real URLs via the history API. Deep links work because
StaticContentResourceserves the shell for any path with no file behind it. Router.start("app-root")once;Router.navigate(path), or an<a data-route href="...">.RmiSecurityContext.onAuthenticatedandonResolvedrun again after every reconnect, so starting the router or registering router listeners there needs a once-only guard.- Guard with
@RequiresRole; it decides what to show, the server still decides what is allowed. - Do not generate a loading spinner, a navigation wrapper, or click and popstate listeners to
drive one. An application gets both with no call at all: a bar, a centered spinner and a wait
cursor once any navigation has run 300 ms, hidden when the latest navigation finishes or fails and
with no timeout of its own (
Router.showBusyIndicator(false)to opt out), and "We could not open this page..." with a Retry button when the latest navigation fails (Router.showFailureMessage(false)to opt out). Turn one off only when the application already renders its own - two saying the same thing is worse than either. Style both with the--zeroz4j-busy-*and--zeroz4j-failure-*custom properties listed in docs/ROUTING.md. - For a custom indicator,
Router.addLifecycleListenerraises started, finished and failed for every way a navigation begins. The navigation started last always ends in exactly one finished or failed; an older one it overtook is superseded and raises nothing more, even when its loader answers later. A redirect is a navigation of its own that supersedes the one that caused it. Router.onErroradds a handler; up to and including 0.9.0 it replaced the previous one.Router.addErrorListeneris the same and returns aDisposable. A failed navigation is never run again by itself after a reconnect;Router.retry()runs it again.
See docs/ROUTING.md.
An RMI call suspends a TeaVM coroutine, and a coroutine cannot suspend on a stack that began in
native JavaScript. Click handlers, setTimeout callbacks and framework callbacks such as
RmiSecurityContext.onAuthenticated are all such stacks, and calling a service directly from one
fails with "suspension point reached from non-threading context".
button.addClickListener(e -> new Thread(() -> service.save(item)).start());That is a green thread re-entering TeaVM's scheduler, not parallelism. The router does this internally for every navigation.
Most of these now throw. The ones that remain are the genuinely silent cases.
| Symptom | Cause |
|---|---|
| A LiveSync'd object updates but the UI does not | The getter was read outside an Effect, so nothing subscribed. Read it inside Effect.create(...). |
An Effect does not re-run |
The value was mutated in place and equals deduplication swallowed it. Use update() with a new instance. |
| A LiveSync collection edit does nothing | Setters are the tracking boundary; obj.getTags().add(x) is invisible. Reassign, or call LiveMutationTracker.touch(obj). |
A rejected sharedWritable write reverts with no explanation |
Shared-signal write rejections are still logged nowhere. LiveSync mutations do report a reason. |
These used to be silent and now fail loudly — expect an exception, not a mystery:
notifyChanged on an unsynced object, an unserializable event or signal payload, a conflicting
shared-signal declaration, bindValue with a non-writable signal, an RMI call while the connection
is down (DisconnectedException, immediately — never a 30-second hang), and — at compile time —
@ClientWritable without @LiveSync or on a field with no setter.
Never add maven-shade-plugin to a ZeroZ Stack server. Weld treats each jar as its own bean
archive with its own beans.xml; a merged jar collapses that and CDI discovery breaks far from
the cause (beans vanish, or WELD-001409 duplicates). The supported shapes, all keeping jars
intact: the default jar + target/libs classpath layout; mvn verify -Ppackage in a generated
project, which runs the JDK's jpackage and produces a self-contained folder with launcher
executable and bundled runtime at <app>-server/target/dist/<app>/; and the generated
Dockerfile, which layers libs/ separately from the app jar. GraalVM native-image is not
supported (EclipseStore uses JDK internals it restricts). See docs/guides/packaging.md.
On Linux, never launch with a bare libs/* classpath wildcard: it expands in arbitrary directory
order, and one of the orders registers Helidon's WebSocket routing after the server is built —
HTTP works, every WebSocket handshake 404s, Windows is unaffected (its filesystem sorts). Build
the classpath sorted: java -cp "target/classes:$(ls target/libs/*.jar | sort | tr '\n' ':')" ….
The generated Dockerfile already does this.
Do not generate reconnect plumbing — the framework recovers by itself: automatic reconnect with
backoff, a built-in "Connection lost" banner (Zeroz4jClient.showConnectionBanner(false) to opt
out), shared signals re-subscribed, live objects re-synced in place, offline signal writes and
@ClientWritable edits queued and flushed. Connection state is a signal:
WasmRmiClient.connectionState() — read it in an Effect to disable controls while not
CONNECTED. What remains application work: retrying an RMI call that failed with
DisconnectedException (never replayed automatically), re-registering anything keyed by session id
(ids change on reconnect; observe SessionClosedEvent server-side to clean up), reacting to a lost
LiveMutex (setLostListener), and re-fetching live objects after a full server restart, which
empties the handle registry that re-sync restores from.
A connection that dies silently is noticed, not waited out. When a call has waited five seconds
with nothing arriving from the server, the client pings; a ping with no answer within ten seconds
closes the socket, so calls on it fail with DisconnectedException and the reconnect starts
(Keepalive.configureLiveness(seconds), zero turns it off). The request timeout (30 s,
WasmRmiClient.setRequestTimeout) runs on a timer of its own and fails an unanswered call with
RequestTimeoutException, which a caller can tell apart from a refusal. Do not generate
application-level heartbeats or watchdog timers for either.
Only a @LiveSync model and the objects inside one carry a handle (0.8.0+), and the registry
holds them weakly on both tiers. Everything else on the wire is a value with a name good for its own
message: it cannot be synced, locked or re-read, and a client sending one back as a call argument
hands over a copy rather than reaching into the server's instance. Keep live objects in your store or
a field — an object the server has dropped answers a re-sync the way a restarted server does. A
re-sync request carries at most 10,000 handles; a client over that throws its list away and re-fetches
rather than sending a message the connection would refuse. A test that registers models by hand calls
BinaryRegistry.registerHandleBearing(fqcn) for the ones standing in for @LiveSync models.
All twelve examples live under zerozstack-examples/. After mvn clean install -DskipTests from the
root, the seven original ones and payments-datamodels have a run.bat (Windows). Every example
binds a port of its own (0.8.0+), so several can run at once — the table below has the numbers.
For those seven there is no executable jar and no exec-maven-plugin — java -jar and
mvn exec:java both fail regardless of what older docs say. The working invocation is the one
run.bat uses:
cd zerozstack-examples/todo-signals/todo-signals-server
java -cp "target/classes;target/libs/*" com.zeroz4j.example.server.ExampleServer # ';' on Windows, ':' on POSIXThey share the main class com.zeroz4j.example.server.ExampleServer. The four added in 0.6.0 do
not: each has a main class of its own, and three of them also build a runnable jar.
payments-datamodels has one of its own too, in its own package:
com.zeroz4j.example.payments.server.ExampleServer.
| Example | Port | Example | Port |
|---|---|---|---|
routing-tour |
8091 | job-monitor |
8087 |
oidc-login |
8081 (needs Keycloak) | form-signup |
8088 |
scoped-signals |
8082 | inventory-crud |
8089 |
pwa-install |
8083 | components-showcase |
8090 |
todo-signals |
8084 | ||
chat-events |
8085 | ||
chat-livesync |
8086 | payments-datamodels |
8092 |
routing-tour, oidc-login and scoped-signals also build runnable jars:
java -jar routing-tour-server/target/routing-tour-server-0.10.0.jar.
To move one: --port 9000 on the command line, or -Dzeroz.port=9000, or — for the seven with
a run.bat — run.bat 9000. Each server reads them in that order and falls back to its own
DEFAULT_PORT constant.
Four of the seven originals require signing in: chat-events, chat-livesync, job-monitor and
components-showcase show a client-side Login component. todo-signals, form-signup,
inventory-crud and payments-datamodels connect anonymously. routing-tour and scoped-signals take credentials from the
URL — ?user=admin&password=admin — which is how you open two windows as different users.
No example enables the development logins by itself. Pass --dev-login to the server main class
(the run.bat scripts do), or set -Dzeroz.security.mode=dev yourself; a server with it on prints
DevAuth.WARNING_BANNER at startup and again on the first sign-in. Do not reintroduce a default:
these classes are what applications get copied from.
Dev credentials are demo / demo (role user) and admin / admin (roles user, admin). The
client passes them as WebSocket handshake parameters and DevAuth validates them. There is no HTTP
login page and no HTTP-level gate: authentication happens at the handshake, and an unauthenticated
visitor loads the page normally and is refused at every @Secured call.
To replace it: implement com.zeroz4j.server.AuthenticationProvider and register it in
META-INF/services/com.zeroz4j.server.AuthenticationProvider. Discovery is via ServiceLoader, not
CDI, because the handshake runs before the endpoint exists. Return an AuthenticatedPrincipal with a
name, roles and optionally a tenant; return null to leave the connection anonymous. Registering a
provider disables the DevAuth fallback entirely.
For OpenID Connect, do not write a provider — depend on zerozstack-auth-oidc and register
com.zeroz4j.server.oidc.OidcAuthenticationProvider, configured with zeroz.oidc.issuer and
zeroz.oidc.clientId. In the browser, OidcClient.start(config, onReady) runs the whole
authorization-code + PKCE flow. Leave zeroz.oidc.audience unset unless the realm has an audience
mapper: a stock Keycloak token carries aud: "account" and names the client in azp.
See docs/guides/oidc-auth.md.
A rejected sign-in is not authenticated. RmiSecurityContext.isAuthenticated() is false and
onAuthenticated(...) does not fire, so gate a login screen on that callback and report failure from
onAuthenticationFailed(...). Do not check for a specific role as a proxy for "did the login work" —
that workaround existed because of a bug fixed in 0.6.0.
Mount the UI from onResolved(...), never onAuthenticated(...). onResolved fires once the
server has answered, authenticated or anonymous — that is the "connection is usable" signal.
onAuthenticated is about identity and never fires for an anonymous connection, so an app with no
login that mounts from it renders a blank page. Build the view on a green thread if it makes an RMI
call: onResolved(() -> new Thread(this::mountUi).start()).
Deploying as a WAR: take zerozstack-server-jakarta instead of zerozstack-server-helidon. It
registers the WebSocket endpoint, calls BinaryRegistry.init() at startup, and supplies the
container's ManagedThreadFactory so RMI calls carry the container's context for naming,
transactions and the caller's identity — without which a java:comp/env/… lookup inside a service
fails. Map Zeroz4jShellServlet yourself;
it is deliberately unmapped so it cannot claim / in a WAR that has its own servlets. Do not add
zerozstack-server-jaxrs to a WAR: it is a catch-all at /. zerozstack-server-core contains no
JAX-RS type, which is what makes it safe inside somebody else's WAR.
zeroz.ws.maxBinaryMessageBytes defaults to 4 MB (0.7.0+), matching gRPC. Set it only to move
away from that. @OnMessage takes a whole message, so anything larger closes the socket rather than
raising an error the application can catch. Do not leave it to the container: on Helidon the Jakarta
WebSocket layer is Tyrus, whose message-assembly limit is Integer.MAX_VALUE — about 2 GB. This
socket is not the route for file uploads. zeroz.ws.idleTimeoutMinutes stops an abandoned tab
holding a session forever; it stays unset by default, leaving the container's own value.
One connection's messages are handled in order (0.8.0+). The server handles a connection's
messages one at a time, in the order the client wrote them, so anything a browser sends after a live
edit - a service call, a lock, a signal write - is handled after that edit. Do not generate a
LiveMutex to order one person's own messages; a lock is for two people editing the same thing.
Ordering is per connection: a slow call for one person never delays anybody else. The keepalive is
answered outside the queue, and a lock request that is waiting for somebody else lets the messages
behind it past, because it has changed nothing yet.
Per-connection ceilings (0.7.0+): 32 messages from one connection may be waiting to be handled
(zeroz.ws.maxQueuedFramesPerSession, called zeroz.ws.maxConcurrentFramesPerSession before 0.8.0
and still read under that name); 256 messages or 8 MB may be waiting to go out
(zeroz.ws.maxPendingFramesPerSession, zeroz.ws.maxPendingBytesPerSession), past which that one
connection is closed with WebSocket code 1013. An empty outgoing queue always accepts the next
message however large, so a single big response is never refused.
File upload (0.7.0+): put FileUpload on a screen and write one @ApplicationScoped class
implementing FileUploadHandler. Files go over their own HTTP address, not the RMI socket — never
put file bytes in an RMI argument. 25 MB per file by default (zeroz.upload.maxBytes). The
temporary file is deleted the moment the handler returns, so move it inside the method. Treat
getFileName() and getContentType() as text the browser typed: generate the stored name yourself.
See docs/guides/file-uploads.md.
A client may only read back, and only lock, an object the server sent that browser (0.7.0+). The
server keeps a record per browser — 10,000 objects, dropped after 24 hours idle
(zeroz.disclosure.maxHandlesPerClient, zeroz.disclosure.idleHours). Ask it yourself with
Disclosures.wasDisclosedTo(session, handleId).
A live change is checked against every object it reaches (0.7.0+), not just the outermost one. A
model nested inside a @ClientWritable model needs its own @ClientWritable, and one refusal
refuses the whole change.
A burst of live edits is one message, not one per setter call (0.8.0+). A @ClientWritable
change waits for the editing to stop for 150 ms, or for 1 second whatever happens, whichever comes
first, and everything changed in that burst travels together - measured at 38 messages before and 4
after for a 38-character sentence typed into the chat-livesync topic box. Change it with
LiveMutations.configure(pauseMillis, ceilingMillis); (0, 0) restores a message per setter call.
Anything the client sends afterwards - a service call, a lock, a signal write - goes on the wire
behind the waiting edit, so do not generate flush-before-save plumbing. Two things this makes true:
leaving the page loses what was still waiting (there is deliberately no unload flush - browsers do
not reliably put bytes on a WebSocket while tearing a page down), and an Effect that copies the
server's broadcast back into the field somebody is typing in now deletes what they typed since, so
follow the incoming value everywhere except the focused field.
An edit that does not reach the server is reported, never dropped (0.8.0+). Whether the server
refused it or the browser could not send it, the screen is put back to the server's state and the
reason goes to LiveMutationRefusals.onRefused(model, reason); with no listener it is written to
the console as a sentence. Nothing is thrown — the setter call finished long before the answer
arrived. Do not generate retry or rollback plumbing for this. The generated registrar records both
the model name and its <Model>_Live subclass name, and the writer puts the model name on the wire;
that pairing is what makes the up direction work at all, so do not simplify registerLive back to
one name.
Unexpected server exceptions reach the client as The server could not complete this request. Reference: <code> (0.7.0+), with the real message in the log under that code. To send a sentence
the caller should read, throw com.zeroz4j.server.ClientVisibleException.
A refusal can be said in the caller's language. Declare .properties files in the shared module
(i18n/app.properties, i18n/app_de.properties) and mark them with an empty class annotated
@MessageCatalog(baseName = "i18n/app", fallback = "en"); the processor writes AppText_Text with
one camel-cased method per key, each taking one argument per {0} blank and returning a Message
rather than a String. Throw new ClientVisibleException(AppText_Text.invoiceApproved(id)) and the
caller reads it in their language while the log keeps English; the String constructor stays correct
for a one-language application. Blanks are {0}, {1} and nothing else - never MessageFormat,
which costs 43% more browser download than every language of text put together. The connection's
language is resolved once at the handshake (lang parameter, zeroz-lang cookie,
Accept-Language, zeroz.i18n.defaultLocale, then en), narrowed to the languages files exist for,
and read with RmiRequestContext.getLocale() - never from a method argument. Nothing checks the
other languages, so add one test calling CatalogParity.assertConsistent(folder, baseName).
English is byte-identical for a project that adds no language. The browser half - a catalog on
the wire, a language picker, live switching - does not exist yet; see
docs/guides/language.md.
Client identity without a login: every connection carries a server-issued, HMAC-signed browser id
in an HttpOnly cookie, readable as RmiRequestContext.getClientId() and used by Scope.CLIENT. It
identifies a browser, not a person. Handshakes are also origin-checked; set zeroz.origins when the
page is served from a different host than the socket, and set zeroz.hosts to the host names the
deployment answers for. zeroz.hosts is unset by default; set, a handshake addressed to any other
name is refused, and the close reason names which of the two checks refused it.
Making an app installable: Pwa.install() in main before connect, plus a manifest and
<link rel="manifest">. Never tell a user this makes the application work offline — every view
loads its data over the socket and there is no client-side store, so opened offline it shows
/zeroz4j-offline.html and nothing else. Do not write a service worker: the framework ships one from
zerozstack-server-core, version-stamped so a deployment evicts the old shell. Gate an install button
on the Pwa.installable() signal, not a one-shot boolean — the browser's offer arrives after the UI
is built. See docs/PWA.md.
| Example | Demonstrates |
|---|---|
todo-signals |
Local signals, Computed, Effect in isolation |
chat-events |
EventTopic / EventPublisher / ServerEvents, deliberately without signals |
chat-livesync |
@LiveSync both ways: the message list comes down into an Effect, the topic box is @ClientWritable and goes up, and LiveMutationRefusals tells the person when the server would not have their edit |
job-monitor |
Signals.shared driven from a server-side virtual thread |
form-signup |
Validation annotations, generated _Rules, Computed form validity |
inventory-crud |
Master-detail CRUD, local signals, Computed KPIs |
components-showcase |
The component library gallery |
routing-tour |
@Route, nested RouteLayout, path and query parameters, @RequiresRole guards, colocated loaders, the router's busy indicator and failure message on a deliberately slow route |
scoped-signals |
Signals.scoped with Scope.CLIENT and Scope.USER beside a global Signals.shared |
oidc-login |
OidcClient PKCE login against Keycloak, and @Secured/@RolesAllowed enforced from its claims |
pwa-install |
Pwa.install(), Pwa.installable(), PwaManifest per request, push subscription, and the offline page |
payments-datamodels |
The three shapes a wire type can take - record, sealed family, and a model extending another model - nested and in collections, both directions, with a TestServer test driving real frames |
- Route loaders do not run in parallel. Client code is a single cooperative scheduler, so a layout's loader and its child's are sequential. The guarantee routing gives is ordering — data before render — not concurrency.
- The route chain is rebuilt on every navigation; a layout is not kept mounted while its children swap, so its loader re-runs.
- Routing has no wildcard or optional segments, no lazy loading, and one child per layout.
- Protocol opcodes
0x11 SNAPSHOT,0x12 UNSUBSCRIBE,0x13 MUTATE,0x14 ACK,0x16 SIGNAL_SUBand0x18 PUSHare declared but unreferenced. There is no version field, no acknowledgment and no conflict rejection in the implemented sync path.0x15 REJECTis implemented: it carries the reason a live mutation was refused. - LiveSync has no field-level merging and no version-conflict detection. Whole-object, last-write-wins.
- Tracked collections do not exist.
- Events have no per-topic server-side subscription filtering.
- No serialization support for object arrays (
String[],MyModel[]— use aList), or forZonedDateTime,OffsetDateTime,ZoneId,Period,java.util.Date. Primitives,String,UUID, enums,BigDecimal,BigInteger,Instant,LocalDate,LocalTime,LocalDateTime,Duration,Optional,List,Set,Map, all primitive arrays, EclipseStoreLazy<T>, records and sealed hierarchies (both 0.8.0+) are supported. - A record cannot be part of a reference cycle, and it cannot be a persistence root. Sending a looped record is refused when it is sent, naming the record; use a class for the type that closes the loop. The persistence rule is separate and unchanged: EclipseStore reaches fields directly and the JVM refuses that for records.
- A sealed hierarchy must be one level deep, every class it permits must be
finaland@DataModel, and a sealed class base must beabstract. All four are compile errors, each because the receiving side could not otherwise tell an allowed type from any other. - A model may extend another model and the base's fields travel too (0.8.0+; before that they
were silently dropped). An abstract model gets no serializer and no registry entry — it exists to
hand its fields down. Two shapes are now compile errors, both formerly silent data loss: extending
a class that is not a
@DataModeland declares instance fields, and redeclaring a field name a base class already uses. - Object identity holds within one top-level value, not between two. The same instance in two
fields of one model arrives once; the same instance as two elements of a top-level
Listarrives twice. Never use==across a call boundary to decide whether two things are the same — compare by id orequals. - A
Lazy<T>field travels as a session-scoped handle, never its contents. The client resolves it with a suspending RMI round trip on firstget(). Lazy references originate on the server; a client cannot create one and send it up. - Declare collection fields as
List,Set,Map, not as concrete types: they are rebuilt asArrayList,LinkedHashSetandLinkedHashMap, so a field typedTreeSetfails with aClassCastExceptionon deserialization.
This page is for working on the framework. Two other documents are for working in an application built on it, and neither is written by hand:
META-INF/zeroz4j/AGENTS.mdinsidezerozstack-shared-api. Generated during this build from therulesarray incontext7.json, stamped with this build's version. Every application resolves that artifact, so an assistant gets rules for the version the project actually depends on rather than for whatever a documentation index is currently serving. Changecontext7.jsonand the jar follows; there is nothing else to edit.AGENTS.mdin a generated project. The archetype writes it, with${zeroz4jVersion}substituted. Source:zerozstack-archetype/src/main/resources/archetype-resources/AGENTS.md. Its##headings are wrapped in Velocity literal blocks because Velocity reads##as a comment and silently drops the line; the file says so at the top.
Every version stated in prose is checked. VersionStatementTest compares it against
<revision> and fails the build, naming the file and line. A sentence about the past keeps its own
number as long as it says so — (0.6.0+), since 0.5.0, before 0.8.0, added in 0.6.0. A
version with no such marker is read as a claim about the current release.
-
Never name anything with a single letter inside a
@JSBodyscript. TeaVM inlines the script as text and renames only the method's parameters, and a minified build renames them to single letters:bfor the first parameter,cfor the second, and so on. A one-letter name inside the script becomes the same name as a parameter, and one of them silently becomes the other. That is how the connection bar came to read[object HTMLDivElement]for two releases, and how a file upload's progress figure never moved. Useidx,ignored,bar,node.JsBodyNamingContractTestreads every Java file in the checkout on every build and fails it otherwise.No ordinary build in this repository is minified - only
routing-tour's client under-Pproduction, whichtools/navigation-proofdrives - and a generated application only is under-Pproduction, so this class of bug is invisible in every ordinary build on both sides. The contract test is the main defense. Do not weaken it, and do not assume a working example proves a@JSBodyscript is safe. -
Apache 2.0 license header on every new
.javafile; copy an existing one. -
Javadoc on public API, including the wire opcode where a method sends a frame.
-
Documentation lives in
/docsas plain Markdown. See docs/contribute/docs-style-guide.md.