Packaging and running¶
How to turn a ZeroZ Stack application into something you can ship. Three supported shapes, one hard rule.
The rule: never shade a ZeroZ Stack server¶
maven-shade-plugin merges every jar into one. That breaks Weld, and it breaks it far from the
cause: Weld treats each jar as a separate bean archive with its own beans.xml and its own
discovery mode. Merging collapses that structure — the many beans.xml files collide and one
survives, its settings now applying to classes from every library, and META-INF/services
registrations overwrite each other unless every transformer is configured exactly right. The
result is beans that vanish, beans that appear twice ("WELD-001409: Ambiguous dependencies"), or
an application that deploys and quietly does nothing.
This is not fixable with more shade configuration. Use one of the shapes below; in all three, every jar stays intact.
Before any of them: which build, and how much it costs¶
A generated project builds its client for the browser at prepare-package, and unoptimized by
default. That gives three commands rather than one, and the difference between them is most of the
time a build takes:
| Command | Browser bundle | Measured on a freshly generated project, warm cache |
|---|---|---|
mvn compile, mvn test-compile |
not built — javac only |
about 5 s |
mvn install |
built, readable and unoptimized | about 16 s |
mvn install -Pproduction |
built, optimized and minified | about 17 s |
The seconds are one laptop's; on a slow continuous-integration machine the same full build is 60 to 90. The ratio is the durable part, and it grows with the amount of client code, because the browser compiler analyzes the whole program and has no usable incremental mode.
Two consequences worth planning around:
A pipeline that stops before package never compiles your user interface
mvn test stops at test, which is before prepare-package. Such a run checks the Java and
nothing else, and client code that the browser compiler rejects — it emulates part of the JDK,
not all of it — passes every check in it. Give a pipeline mvn verify. For the same reason,
run a full build yourself before calling a piece of client work finished.
Build and run -Pproduction before you ship, not at release time
It is the only command that produces the shape your users get. Minification renames things, and
renaming can break code a readable build runs perfectly: it is why this framework's own outage
banner read [object HTMLDivElement] for two releases. Start the production build, click
through it, and read the browser console. Combine it with the shapes below —
mvn verify -Pproduction,package.
Shape 1: the development layout (what the archetype builds)¶
mvn package produces the server jar plus every dependency in target/libs/. Launch with a
classpath:
cd myapp-server
java -cp "target/classes;target/libs/*" com.mycompany.server.ServerApp # Windows
java -cp "target/classes:target/libs/*" com.mycompany.server.ServerApp # Linux / macOS
The compiled client is embedded in the server's resources (META-INF/resources), so the server
is the whole application. This layout is the base for both shapes below. target/libs is emptied
before it is refilled on every build, so a version bump cannot leave two framework jars on the
classpath.
On Linux, sort the classpath
A libs/* wildcard expands in directory order — alphabetical on Windows, arbitrary on
Linux. One of the arbitrary orders loads Helidon's CDI extensions in a sequence where the
WebSocket routing registers after the server was already built. The symptom: HTTP works, the
page loads, and every WebSocket handshake answers 404 — on Linux only. Found by running
the same jars on both systems; any deterministic order fixes it. On Linux, launch with a
sorted explicit classpath:
java -cp "target/classes:$(ls target/libs/*.jar | sort | tr '\n' ':')" com.mycompany.server.ServerApp
The generated Dockerfile already does this, and on Windows the wildcard happens to be safe
because the filesystem returns sorted entries.
Shape 2: a double-clickable executable (jpackage)¶
For handing the application to someone as a file. Projects generated from the archetype (0.6.0+)
carry a package profile:
This produces myapp-server/target/dist/myapp/:
myapp/
├── myapp.exe ← the launcher (bin/myapp on Linux, myapp.app on macOS)
├── app/ ← your jar and every dependency jar, unmodified
└── runtime/ ← a bundled Java runtime
Ship the folder; the target machine needs no Java installation. The build uses the JDK's own
jpackage tool, so the build machine needs nothing beyond the JDK either. The image is built
for the OS you build on — a Windows build makes a Windows app; build on each OS you ship to.
Two details worth knowing:
- The EclipseStore data directory is resolved relative to the working directory at launch,
same as when running from Maven. Set
zeroz4j.store.directoryto an absolute path for an app you distribute, or it will writedata/wherever it was double-clicked from. - To make an installer (
.msi,.deb,.dmg) instead of a folder, change--type app-imagein the profile to--type msi(Windows needs the free WiX toolset installed for this),debordmg. The folder form is the default because it needs no extra tooling anywhere.
Shape 3: a container image (servers and cloud)¶
Projects generated from the archetype carry a Dockerfile at the root:
The dependency jars are copied as their own image layer, below the app jar. Rebuilding after a code change re-pushes only your application's few kilobytes; the framework layer is cached and shared. No shading anywhere — the container launches the same plain classpath as shape 1.
What about a single executable jar or a native binary?¶
- Spring Boot-style nested jars solve the merge problem with a custom launcher classloader — which is exactly the kind of environment Weld's archive scanner mis-handles. Not supported.
- GraalVM native-image is attractive and not yet realistic here: EclipseStore leans on JDK internals that native-image restricts. If that changes, this page will change.
- jlink custom runtimes require fully modularized dependencies, which Weld and friends are
not.
jpackagedelivers the same "no Java install needed" result without that fight.
Which shape when¶
| You want | Use |
|---|---|
| To develop and run locally | Shape 1, or just run.sh / run.bat |
| To give the app to a person or run it as a plain OS process | Shape 2 (mvn verify -Ppackage) |
| To deploy to a server, Kubernetes, or any cloud | Shape 3 (docker build) |
| One merged jar | Nothing — see the rule at the top |
Settings a real deployment needs¶
Whichever shape you ship, set these before anyone outside your machine can reach the application. They all default to the behavior that is convenient during development, which is not the behavior you want in front of the internet.
| Property | Set it to | What it does |
|---|---|---|
zeroz.hosts |
Every name the application is reached by, e.g. app.example.com |
The server accepts a connection only when it was addressed to one of these names. Unset, it accepts any name at all. |
zeroz.clientId.secret |
A long random string, the same on every node | The key that signs the browser id. Unset, a new one is made at every startup, so a restart gives everybody a new browser id and other nodes do not recognize this node's ids. |
zeroz.origins |
Leave unset, unless the page is served from a different host than the socket | Unset means the page must come from the same address as the socket, which is what you want. |
zeroz.security.mode |
Leave unset | Setting it to dev switches on two built-in accounts whose passwords are printed in this documentation. |
zeroz.ws.maxBinaryMessageBytes |
e.g. 8388608 |
The largest message the server accepts. 4 MB unless you say otherwise; see below. |
java -Dzeroz.hosts=app.example.com \
-Dzeroz.clientId.secret=$MY_SECRET \
-Dzeroz.ws.maxBinaryMessageBytes=8388608 \
-jar myapp-server.jar
Serve it over HTTPS. The identity cookie is marked Secure, which a browser only keeps on an
https:// page. Behind a proxy that terminates TLS — where the application itself only ever sees
plain HTTP — set zeroz.clientId.secureCookie=true so the cookie keeps that mark anyway.
The development accounts are described in
Authentication and authorization. The examples take a
--dev-login flag to switch them on; nothing switches them on by itself, and a server that has them
on says so at startup.
Every setting the framework reads¶
All of them are JVM system properties: -Dname=value on the command line. Every one has a working
default, and a fresh application needs none of them set.
The connection¶
| Property | What it does | Unset |
|---|---|---|
zeroz.origins |
Which pages may open a connection. A comma-separated list, or * for no check |
the page must come from the same address as the socket |
zeroz.hosts |
Which host names this deployment answers for. A comma-separated list; an entry with no port accepts that name on any port | any name is accepted |
zeroz.ws.maxBinaryMessageBytes |
Largest message the server accepts | 4 MB (4,194,304 bytes) |
zeroz.ws.idleTimeoutMinutes |
How long a silent connection is held before closing | the container's own timeout |
zeroz.ws.maxQueuedFramesPerSession |
Messages from one connection that may be waiting to be handled. The name changed in 0.8.0; the old name zeroz.ws.maxConcurrentFramesPerSession is still read |
32 |
zeroz.ws.maxPendingFramesPerSession |
Messages that may be waiting to go out on one connection | 256 |
zeroz.ws.maxPendingBytesPerSession |
Bytes that may be waiting to go out on one connection | 8 MB (8,388,608 bytes) |
zeroz.ws.keepaliveMinIntervalMillis |
Shortest gap between two keepalive answers to one connection | 1000 |
Who the caller is¶
| Property | What it does | Unset |
|---|---|---|
zeroz.clientId.secret |
The key that signs the browser id. Set the same value on every node | a new key at every startup |
zeroz.clientId.ttlDays |
How long a browser id stays valid | 365 |
zeroz.clientId.secureCookie |
Forces the Secure mark on the identity cookie on or off |
follows the request: on for https, off for http |
zeroz.security.mode |
dev switches on the built-in demo and admin logins |
off |
Editing locks¶
| Property | What it does | Unset |
|---|---|---|
zeroz.livemutex.waitSeconds |
How long a caller waits for a lock somebody else holds | 30 |
zeroz.livemutex.requireAuthentication |
true allows locking only on signed-in connections |
off |
The record of what was sent to each browser¶
| Property | What it does | Unset |
|---|---|---|
zeroz.disclosure.maxHandlesPerClient |
Objects remembered as sent to one browser | 10,000 |
zeroz.disclosure.idleHours |
How long that record survives with no activity | 24 |
File uploads¶
| Property | What it does | Unset |
|---|---|---|
zeroz.upload.maxBytes |
Largest file the server accepts | 25 MB (26,214,400 bytes) |
zeroz.upload.passSeconds |
How long an upload permission stays usable | 60 |
zeroz.upload.tempDir |
Where a file is written while it arrives | zeroz4j-uploads inside the system temporary directory |
Language¶
| Property | What it does | Unset |
|---|---|---|
zeroz.i18n.defaultLocale |
What language the server answers in when the browser has not said what it reads | en |
Deliberately not the machine's own locale: a server in Frankfurt has a German JVM locale that has nothing to do with whoever is calling it. Full explanation in Answering in the reader's language.
Inside an application server¶
| Property | What it does | Unset |
|---|---|---|
zeroz.threads.jndiName |
The JNDI name of the thread factory RMI calls run on | java:comp/DefaultManagedThreadFactory |
Logging in with OpenID Connect¶
Read only when zerozstack-auth-oidc is on the classpath. Full explanation in
Logging in with OpenID Connect.
| Property | What it does | Unset |
|---|---|---|
zeroz.oidc.issuer |
The realm URL that appears in your tokens' iss claim |
required — startup fails without it |
zeroz.oidc.jwksUri |
Where the signing keys are published | Keycloak's location under the issuer |
zeroz.oidc.clientId |
This application's client id | none |
zeroz.oidc.audience |
The aud value a token must carry |
the azp claim is compared with clientId instead |
zeroz.oidc.principalClaim |
Which claim becomes the user name | preferred_username |
zeroz.oidc.rolesClaim |
A flat claim holding the roles | Keycloak's own role structure is read |
zeroz.oidc.tenantClaim |
Which claim carries the tenant | none |
zeroz.oidc.tenantFromRealm |
true takes the tenant from the realm in the issuer URL |
off |
zeroz.oidc.clockSkewSeconds |
How much clock difference an expiry check tolerates | 60 |
Storage has its own settings under a different prefix, zeroz4j.store.* — see
Store modes.
Shape 4: a WAR on a Jakarta EE server¶
Everything above assumes ZeroZ Stack brings its own server. It does not have to. To deploy into an
application server — WildFly, Payara, Open Liberty, TomEE — take zerozstack-server-jakarta instead
of zerozstack-server-helidon:
<dependency>
<groupId>com.zeroz4j</groupId>
<artifactId>zerozstack-server-core</artifactId>
</dependency>
<dependency>
<groupId>com.zeroz4j</groupId>
<artifactId>zerozstack-server-jakarta</artifactId>
</dependency>
That module carries the things every WAR otherwise had to work out for itself:
| Class | Does | Needs configuration? |
|---|---|---|
Zeroz4jServletBootstrap |
Calls BinaryRegistry.init() at startup, so the generated serializers load |
No — @WebListener |
Zeroz4jWebSocketConfig |
Publishes the /wasm-rmi endpoint, which container scanning does not find inside a dependency jar |
No |
ManagedThreadFactoryProvider |
Runs RMI calls on container threads (see below) | No — registered via META-INF/services |
Zeroz4jShellServlet |
Serves the client bundle and the shell for client-route deep links | Yes — map it yourself |
FileUploadServlet |
Receives file uploads at /zeroz4j-upload |
No — @WebServlet |
The servlet is deliberately unmapped: where it sits is the deployment's decision, and a WAR with its
own servlets must be able to take this module without something claiming /. Map it in web.xml:
<servlet>
<servlet-name>zeroz-shell</servlet-name>
<servlet-class>com.zeroz4j.server.jakarta.Zeroz4jShellServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>zeroz-shell</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
Do not add zerozstack-server-jaxrs to a WAR unless you want its catch-all. That module carries a
JAX-RS application at / answering every unmatched path — right for a standalone server, and a
collision inside a WAR that has its own servlets. It is a separate module precisely so you can leave
it out; zerozstack-server-core contains no JAX-RS type at all.
Container threads¶
By default the framework dispatches RMI calls onto virtual threads it creates itself. Inside an
application server that is a problem: the container attaches thread-locals — the naming context
behind java:comp/env/…, the transaction context, and the caller's identity — before calling
application code, and a thread the container did not create has none of them. A service doing a JNDI lookup, or holding
an @Resource resolved lazily on the calling thread, then fails a long way from the cause.
It cannot be repaired from inside such a thread by handing the work to a ManagedExecutorService:
there is no context on that thread to capture. It has to be right at thread creation, which is why
the SPI is a ThreadFactory:
public final class MyThreadFactoryProvider implements SessionThreadFactoryProvider {
@Override
public ThreadFactory threadFactory() {
return InitialContext.doLookup("java:comp/DefaultManagedThreadFactory");
}
}
zerozstack-server-jakarta already registers exactly this, so the dependency is normally all you
need. Point it elsewhere with -Dzeroz.threads.jndiName=… if your container publishes its factory
under another name. If the lookup fails the framework logs a warning and falls back to virtual
threads — degraded but running, rather than a deployment that will not start.
Container threads are platform threads
A Jakarta EE 10 ManagedThreadFactory cannot produce virtual threads. Inside a container that is
the right trade — context matters more than cheap threads — but it is a trade, and worth
knowing before a load test surprises you.
The framework's side of this contract is narrow: calls are dispatched on threads the supplied factory produced. Whether a particular container's factory really carries naming, transactions and the caller's identity is that container's contract, and worth asserting in your own integration test.
How big a message can be, and what happens when one is too big¶
The server accepts messages up to 4 MB (zeroz.ws.maxBinaryMessageBytes), the same default
gRPC uses.
The framework sets that number itself rather than taking the container's. It uses the Jakarta
WebSocket API, which Helidon 4.0.8 implements by embedding Tyrus 2.1.5, and Tyrus starts each
connection's message limit at Integer.MAX_VALUE — about 2 GB. A message is assembled whole before
any of your code sees it, so that number is what the server would be prepared to hold in memory for
one connection.
An explicit setting always wins, up or down, so a deployment that already tuned this keeps its number:
The server logs the limit in force once at startup, naming the property, so a message that is refused later can be explained without guessing.
When a browser stops reading¶
zeroz.ws.maxPendingFramesPerSession and zeroz.ws.maxPendingBytesPerSession are about the other
direction: messages the server is trying to send.
The server sends a message by handing it to the operating system, which puts it on the network as fast as the browser accepts it. A browser that has stopped accepting — a laptop that went to sleep, a phone that lost signal, a tab that is wedged — makes that hand-off stop part-way. The server keeps the messages for that one connection in a queue until it starts moving again.
The queue has a size, and the two settings above are it.
Reaching either one closes that connection with WebSocket code 1013, "try again later", and writes
a line to the log saying which limit was hit and what the setting is called.
An empty queue always accepts the next message however large it is, so a single big response is
never refused; the limits are on what piles up behind it.
Closing is deliberate. A browser that is that far behind has already missed messages it will never see, so its copy of your data is wrong whichever choice is made, and the client reconnects and asks for a fresh copy on its own. Holding the queue open instead would mean one connection deciding how much memory the server uses.
Nothing else on the server waits for a connection in this state. Each connection has its own queue and its own thread that empties it, so a browser that has stopped reading holds up its own messages and nothing else — not another browser's, and not a broadcast on its way to everyone.
Raise the limits if you send large messages in bursts and you would rather a struggling connection recovered than dropped. Lower them if you have many connections and want a stalled one dealt with sooner.
What a client sees when a message is too big: the connection closes. There is no error response
and no exception you can catch. The engine's @OnMessage takes a whole message — there is no
partial-message handling — so an over-sized message never reaches framework code at all. The client
reconnects automatically, so the symptom is a socket that drops every time one particular call is
made.
If a response is genuinely over the limit, either raise the limit or return less: page the results, or return identifiers and fetch details on demand.
This connection is not for file uploads
Do not send file contents over the RMI socket. It is sized for the messages an application exchanges, not for documents, images or video, and a big enough file simply closes the connection. Use file upload, which posts to its own HTTP address and streams straight to disk.
How many messages one connection may have waiting¶
One connection's messages are handled one at a time, in the order they were sent (0.8.0+). Up to
32 may be waiting their turn (zeroz.ws.maxQueuedFramesPerSession). A message that arrives when
that is full makes the connection wait until there is room — nothing is dropped and no call fails, so
a burst is served one after another. The waiting happens on that one connection's own read thread, so
other connections carry on unaffected.
Decoding is where a small message turns into a large object graph, so the size limit above is a real ceiling on memory only when the number of messages queued behind it is bounded too.
This setting was called zeroz.ws.maxConcurrentFramesPerSession before 0.8.0, when it capped how
many messages from one connection ran at the same time. There is no such concurrency now — exactly
one runs — so the name changed. A deployment that set the old name keeps working: it is still read
when the new one is not set. Nothing has to change.
The idle timeout¶
zeroz.ws.idleTimeoutMinutes is unset by default, so an abandoned browser tab holds a session and
its server-side resources for as long as the container allows. The client reconnects by itself, so
closing an idle connection is invisible to a user who comes back.