Every known gap in ZeroZ Stack 0.9.0-SNAPSHOT, in one place. This page exists because surprises are what make people abandon a framework, and because a coding agent that reads it will not generate code against features that do not exist.
ZeroZ Stack is an experimental proof-of-concept. Read this list as the honest boundary of the demonstration, not as a roadmap commitment.
These exist in the source as annotations or constants and do nothing. Do not build on them.
| Item | Status |
|---|---|
Protocol opcodes 0x11 SNAPSHOT, 0x12 UNSUBSCRIBE, 0x13 MUTATE, 0x14 ACK, 0x16 SIGNAL_SUB, 0x18 PUSH |
Declared and unreferenced. Reserved for future protocol work. (0x15 REJECT left this list in 0.4.0 and is now sent for every refused live mutation.) |
| Versioned mutations, acknowledgment and conflict rejection | Reserved in the protocol. The implemented sync path has no version field, no ACK and no conflict detection. A refusal is reported, but nothing counts versions. |
| UI-scheduler dispatch of inbound frames | Conditional on a PlatformScheduler, and WasmRmiClient.setPlatformScheduler is never called anywhere in the framework, so all inbound frames are applied inline on the WebSocket callback. (Outgoing mutations no longer depend on it: since 0.8.0 they are coalesced by a real timer - see LiveSync.) |
Since 0.5.0 a dropped WebSocket recovers by itself: the channel reconnects with backoff, a built-in
banner shows the outage, shared signals re-subscribe, live objects are re-synced from the server,
edits and writes made while offline are sent on reconnect, and RMI calls fail immediately with
DisconnectedException instead of hanging. Since 0.6.1 an idle connection also sends a keepalive
every 25 seconds, so a proxy in front of the application does not close it for silence.
What automatic recovery deliberately does not cover:
- RMI calls are never replayed. A call that failed to a drop is the application's to retry — the
framework cannot know whether repeating it is safe. Catch
DisconnectedException, or disable controls whileWasmRmiClient.connectionState()is notCONNECTED. - A server restart empties the handle registry. Re-sync can only restore objects the server still knows. After a restart, live objects held by clients stay as they were and the application must re-fetch them the way it first obtained them; the server logs how many handles it could not restore. Shared signals recover fully either way.
- Session ids change on reconnect. Anything keyed by session id —
Scope.SESSIONpushes, application registries of "sessions viewing X" — points at a dead session after a drop. The application must re-register from aStateListeneronCONNECTED; observe the server-side CDI eventSessionClosedEventto clean up the stale entry. - A lost
LiveMutexstays lost. The server releases a session's locks when the socket closes. Reconnecting does not re-acquire; the holder is told throughsetLostListener. Taking the lock again after a reconnect works, because the right to lock an object is remembered per browser, not per connection. - Events broadcast during an outage are gone. Events are fire-and-forget news with no replay; this is unchanged and by design. State belongs in signals or LiveSync, which do recover.
- Offline writes are last-write-wins. A shared-signal write queued offline flushes as one write with the final value; intermediate values are not replayed. Concurrent edits from other clients during the outage are settled by the server's usual last-write-wins rules, not merged.
- Change notification is per object, not per field. Any inbound sync touching an instance re-runs every effect that read any of its getters. Fine-grained per-field tracking is not implemented.
- Whole-object, last-write-wins. No field-level merging. Two concurrent unlocked editors race and
the later write wins; serialize them with
LiveMutexwhere that matters. - You can lock only an object the server sent you. A
LiveMutexrequest naming an object this browser was never sent is refused straight away, with a message saying so. Being sent an object is what earns the right to lock it; knowing its handle is not. No sign-in is required, because applications with no login use locking too; a deployment that has logins can additionally require one withzeroz.livemutex.requireAuthentication=true. - A lock is waited for at most 30 seconds. Then the call fails with a message naming the wait,
and nothing is changed.
zeroz.livemutex.waitSecondsmoves it. Callers are served in arrival order. - The lock table only holds locks in use. An object has an entry while somebody holds its lock or is waiting for it, and the entry goes the moment the last of them leaves. So the table is bounded by how many locks are actually in use right now, not by how many object names have ever been presented. There is no separate ceiling and no expiry: a lock held for ever by a live session stays held, which is what a lock is for.
- No tracked collections. Setters are the tracking boundary; in-place collection edits are
invisible. Reassign through the setter or call
LiveMutationTracker.touch(obj). notifyChangedrequires a prior send. It throwsIllegalStateExceptionunless the object already has anObjectMapperhandle, which it gets by being serialized to a client.- Rejections carry a reason. The writer receives a corrective sync followed by a
0x15 REJECTframe naming the model and the reason, and every rejection cause is logged server-side. - Mutations do not coalesce in the current build — one frame per setter call. See the table above.
- A handle lasts exactly as long as the object. Since 0.8.0 the handle registry holds what it names weakly, on both tiers, so an entry disappears once the application has let go of the object. There is no ceiling and no expiry, and none is needed. The consequence to know: a live object the server no longer holds cannot be re-synced, and answers a re-sync the same way a restarted server does — nothing comes back, the count is logged, the client re-fetches. Keep live objects in your store or a field. Before 0.8.0 nothing was ever removed and handles accumulated for the process lifetime, which filled a real application's heap.
- Only a
@LiveSyncmodel and the objects inside one get a handle. Everything else on the wire is a value with a name good for its own message only. So an ordinary value returned from a service method cannot be synced, mutated, locked or re-read by handle — and a client that sends one back up as a call argument now hands over a copy, where before 0.8.0 it reached into the server's own instance and overwrote it. - A re-sync request carries at most 10,000 handles. A client holding more throws its list away, logs one line, and lets the application re-fetch. The number matches the server's own per-browser record, which holds no more than that either.
- Only
@ClientWritableclasses accept client writes, and only objects the server has already synced can be mutated. - A refusal is a sentence, not an exception. The server sends back both the corrected state and
the reason; the client applies the state and hands the reason to any
LiveMutationRefusals.onRefused(...)listener. With no listener it goes to the browser console. Nothing is thrown, because the edit was already over by the time the answer arrived. - An edit waits before it travels, and leaving the page loses what is still waiting (0.8.0+).
A change is sent after the changes stop for 150 ms, or after 1000 ms whatever happens - whichever
comes first (
LiveMutations.configure). So a burst of typing costs a handful of messages instead of one per character. What it also means: somebody who closes the tab or follows a link mid-burst loses up to a second of typing. There is deliberately no flush when the page is left, because a browser will not reliably put bytes on a WebSocket while it is taking the page apart, and a rescue that works half the time is worse than none. Lower the ceiling for a screen where a second matters. - One connection's messages are handled in the order they were sent (0.8.0+). Anything a client
sends after an edit - a service call, a lock, a signal write - goes on the wire behind that edit,
and the server handles it after that edit. A service method whose correctness depends on an edit
made a moment earlier is safe without a
LiveMutex. Two exceptions, both deliberate: the keepalive ping is answered straight away rather than queued, and a lock request that is waiting for somebody else lets the messages behind it past, because it has changed nothing yet. Before 0.8.0 the server handled up to 32 messages from one connection at once and this was a real hazard. - The server's own broadcast will fight a text field, if you let it. An accepted edit is sent
back to everybody including its author, carrying the value the server had a moment ago. An
Effectthat copies that into the field somebody is typing in deletes what they typed since. Follow the incoming value everywhere except the field that has the keyboard; see the pattern in LiveSync. - An edit that cannot be sent is announced, not retried. If a change cannot be put on the wire,
the client asks the server to re-send that object — which puts the screen back to the truth — and
reports it to
LiveMutationRefusals. It is not queued and it is not tried again. (An edit made while the connection is down is a different thing, and that one is kept and sent on reconnect.)
- Broadcast only. No per-topic subscription filtering on the server; a session receives every frame published within its scope and filters by topic client-side.
- Scoping is opt-in.
publish(topic, payload)reaches every connected session with no principal check. UsepublishToUserorpublishToSessionfor anything belonging to somebody. - Tenant scope requires a provider that reports a tenant.
Scope.TENANTfilters on the tenant anAuthenticationProviderattached to the session; a session with no tenant never matches. - At most once. A disconnected client misses events. No queueing, acknowledgment or redelivery.
- No replay. Late subscribers receive nothing.
- Serialization failures throw to the caller. The payload is checked once before the broadcast, so
publishfails loudly instead of appearing to succeed while reaching nobody.
Signals.sharedis JVM-global. The registry is static: one value per signal name for the whole server, across every user and tenant. That is the definition, not a gap — for one value per tenant, user, browser or session useSignals.scoped(name, initialValue, scope)instead (see SIGNALS.md).- Scoped signals hold every target's value for the process lifetime. Targets are created on first
use and never evicted, so a
Scope.CLIENTsignal in a long-running server accumulates one entry per browser that ever connected. Nothing pages them out or persists them across a restart. - A scoped signal's targets are not enumerable from a client, and
knownTargets()on the server reports only targets that have been touched — a target that has never been written is absent even though subscribing to it works and yields the initial value. - One default signal per payload type. The default wire name is the payload's class name, so two
unnamed declarations of the same type collide. A conflicting redeclaration now throws
IllegalStateExceptionrather than silently keeping the first; give signals explicit names. - Latest-wins only. No history, no replay, and
equals-equal consecutive values are dropped, so a rapidly changing signal can skip intermediate states. - Whole-value replacement. No per-field merging on client writes.
- Empty
writeRolesmeans anyone may write, anonymous sessions included. - Serialization failures throw to the caller — the value is checked once before the broadcast.
- A rejected client write is logged nowhere. The writer is snapped back with no server-side record. (Shared-signal writes still lack the reason frame that LiveSync mutations now get.)
- Validation on a client write checks the top-level value only — it does not recurse into fields or collection elements.
- Treat the signal graph as single-threaded.
ValueSignalsynchronizes its own reads, writes and listener notification, butComputedhas no synchronization at all, so the graph as a whole is not thread-safe even though one type in it is. KeyedListhas to be disposed by you. Since 0.8.0 it implementsDisposable, so its effect can be released — but nothing releases it for you. Keep the objectnew KeyedList<>(...)gives back and calldispose()on it when the screen leaves, normally fromonDetach. Before 0.8.0 it handed out nothing to stop it with, so every one ever built kept watching its signal for ever.bindTextandbindValuereturn their ownDisposablethe same way.bindValuerequires a writable signal. Passing aComputedthrows rather than silently degrading to one-way; usebindValueReadOnlywhen a one-way binding is what you want.
- No converters.
Binderbinds a field's value type directly to the bean property type; there is nowithConverter, so aStringfield cannot be bound to anintproperty. Use a field whose type matches, or convert in the getter/setter pair you pass tobind. - One validation message per field is surfaced at a time — the first violation wins.
- No
setReadOnlyon the binder or its bindings, and no validation-status handler hook. - A custom
HasValueimplementation must implementaddValueChangeListenerandremoveValueChangeListenerorBinderthrowsUnsupportedOperationExceptionwhen binding it. This is deliberate; fields extendingAbstractFieldalready satisfy it. - A
Computedstill returns its last value afterdispose().
- Transactions and rollback are available since 0.4.0, because the store runs on ZeroZ DB.
Send a
DbCommandthrough the injectedZeroZDbNode: everything it enlists commits atomically, and a command that throws persists nothing and restores the objects it touched. The rawEmbeddedStorageManagerremains available where the data is local, wherestoreAll(...)groups a write but cannot undo one. - Each
store()call is its own commit. Two calls where one was meant is the most common data-loss bug: a crash between them persists the first and loses the second. UsestoreAll. - No conflict detection at the storage layer. Two writers changing the same object: the later write wins, silently.
- Saving is manual. The framework never writes for you, including after a
@ClientWritableedit from a browser — implementLiveMutationListeneror the edit is lost on restart. - No query language. Reads are plain Java over the in-memory graph, so lookups are linear scans unless the application keeps its own index.
- Uncommitted state is visible. A change is in the object graph as soon as you make it, whether or not it has been saved, so another request can read it.
Supported:
-
Primitives and their wrappers:
int,long,double,float,boolean,short,byte,char -
String,UUID, enums -
BigDecimalandBigInteger— carried as their exacttoString()form, so scale and precision survive; safe for monetary amounts -
Instant,LocalDate,LocalTime,LocalDateTime,Duration -
Optional— empty and present both round-trip -
Collections:
List,Set,Map -
Arrays:
byte[],int[],long[],double[],float[],short[],char[],boolean[] -
@DataModelclasses, including cycles and shared references -
@DataModelrecords (0.8.0+) — but a record cannot take part in a reference cycle, cannot be@LiveSyncor@ClientWritable, and cannot be a persistence root -
@DataModelsealed interfaces and sealed abstract classes (0.8.0+) — a field, list element, argument or return value declared as the sealed base arrives as the real member type. Every permitted type must itself be@DataModelandfinal, and must not itself be sealed -
Fields inherited from a base
@DataModel(0.8.0+). Before that they were dropped silently. Extending a non-@DataModelclass that has fields of its own, and shadowing a base field name, are both refused when you compile -
EclipseStore
Lazy<T>fields — see below
Not supported: object arrays (String[], MyModel[] — use a List), ZonedDateTime,
OffsetDateTime, ZoneId, Period, java.util.Date, java.sql.*.
Since 0.4.0 the annotation processor rejects an unsupported @DataModel field type at compile
time, naming the replacement, so the mistake no longer waits until runtime. The check is a
blocklist of types known to break, not an allowlist: a field typed Object, an interface or an
abstract class still compiles, because serialization dispatches on the runtime type.
A @DataModel may declare EclipseStore Lazy<T> fields. The reference travels as a session-scoped
handle and never as its contents, so a deferred subgraph stays deferred across the network:
@DataModel
public class Order {
private String id;
private Lazy<List<OrderLine>> lines; // handle on the wire
}// Client — suspends on the round trip, then caches. Reading again is free.
for (OrderLine line : order.getLines().get()) { ... }The server holds a real Lazy.Default backed by storage; the client holds a ClientLazy backed by an
RMI call. Both satisfy the same interface, so your model is unchanged. TeaVM links only the Lazy
interface and eliminates every EclipseStore implementation behind it — no storage class reaches the
browser bundle.
Limits and rules:
- Handles are bound to the session they were sent to. Another session presenting the same handle is refused, because a handle is a capability to read a subgraph.
- Handles are released when the session closes, unlike
ObjectMapperentries. - Lazy references originate on the server. A client cannot create one and send it up; assign the resolved value instead.
- The client and server must agree on the EclipseStore version — the
Lazyinterface changed shape between major versions, and a mismatch shows up as an obscurecannot access UsageMarkablecompile error. Both sides take the version from theeclipsestore.versionproperty. isStored()is always true on the client, andlastTouched()returns 0; usage marks drive server-side cache eviction and are inert in the browser.- Resolving a lazy field is a round trip. It is not automatically batched with anything else, so resolving many in a loop makes many calls.
Declare collection fields as the interface type — Set, List, Map — not as TreeSet,
LinkedList or TreeMap. Collections are rebuilt on the receiving side as LinkedHashSet,
ArrayList and LinkedHashMap, so a field declared as a concrete type outside that hierarchy fails
with a ClassCastException on deserialization. A TreeSet is written in its sorted order and arrives
ordered but without its Comparator, so later insertions are not re-sorted.
UUID is carried as its canonical string form rather than two longs, because TeaVM does not emulate
UUID.getMostSignificantBits().
- Server-side RMI argument validation recurses into
Listelements but not intoMapvalues or nested object fields. - Client-side validation is user feedback. It decides nothing; the server re-validates independently.
Both tiers answer in the reader's language, and somebody can pick one and watch the screen change. What exists and what does not:
Works. The connection's language is decided at the handshake — from a lang parameter, a
registered LocalePreferenceStore, a zeroz-lang cookie, the browser's Accept-Language header,
zeroz.i18n.defaultLocale, then English — and is readable in a service as
RmiRequestContext.getLocale(). Every refusal the framework produces, and any
ClientVisibleException an application throws with a message from a catalog, is written in that
language on the wire and in English in the log. An application declares a catalog with
@MessageCatalog and gets one compile-checked method per key. The words for the resolved language
reach the browser on the AUTH frame, before the first screen is built. LanguageSelector offers
exactly the languages the deployment has words for, and choosing one changes the words on screen
without rebuilding anything — a half-filled form keeps its values.
Does not work.
- Plural rules of any kind. Substitution replaces
{0}with a value and does nothing else, so"{0} tasks left"says "1 tasks left" and the application has to choose between two keys itself. Java's ownChoiceFormatwould cover the two-form languages and costs 57 KB gzip in the browser — more than every language of translated text this project will ever ship — and is still wrong for Polish, Russian, Arabic and Welsh, which need three to six forms chosen by rules it cannot express. There is no ICU in TeaVM. - Right-to-left layout. Arabic and Hebrew need mirrored layouts, not just translated words, and
the component library uses physical directions (
margin-left,text-align: right) throughout and has never been looked at in a mirror. Translating the words without doing the layout produces something worse than English. Not designed, not tested, not planned. - Numbers, dates and money are off by default.
Formatsexists and is opt-in, because the first call into it adds 233 KB to the bundle and 43 KB to every visitor's download, gzipped, with about 36 KB (6 KB gzipped) more per locale named injava.util.Locale.available. Nothing in the framework calls it, and a build check keeps it that way. Until an application asks, a translated screen still prints1,234.56to a German reader. - The framework's own words inside the browser — the reconnect banner,
Closeon a drawer,Copiedafter a copy button, the offline page — are English literals and are not in a catalog.LanguageSelectoris the exception; its own name is a catalog entry. - The framework ships two languages, English and German, and no more. German because it is the
language this project's author writes, so the translation can be reviewed rather than guessed at;
a translation nobody here can check is worse than one language, because a wrong sentence in a
language you cannot read looks exactly like a right one. A third is a
.propertiesfile somebody contributes. Adding one costs the browser nothing. - The framework's own languages are deliberately not offered to a person.
i18n/zeroz4j_de.propertiesis on every application's classpath, translated or not, so if it counted every deployment would offer German - and a German browser would be answered with German refusals over an English screen. What a deployment can answer in is decided by its own catalogs, and the framework's words ride along with whichever of them it has. A server with no interface at all, wanting German refusals and holding no catalog of its own, says so withzeroz.i18n.defaultLocale. - The language is remembered per browser. A second computer, or a private window, starts from
that browser's
Accept-Languageagain — unless the application registers aLocalePreferenceStoreof its own. - Validation messages are not translatable. They are compiled into
<Model>_Rulesas written. - There is no check for English left hard-coded in a screen. No check can reliably tell a sentence a person reads from a CSS class, a DOM attribute or a log line.
- A translation whose fallback lives in another module is checked, but only in this repository.
Overriding the framework's own wording means a
zeroz4j_de.propertiesin your module whose fallback stays inzerozstack-shared-api, andCatalogParitycompares a file with its siblings in one folder - so it cannot check that pairing for you. A key you leave out falls back to the framework's own wording for that one sentence, which reads as two voices in one screen. - The stale-label check has holes it cannot close.
MessageReadContractTestfails the build whenMessage.text()is called outside an effect, and it reads one file's text: it cannot follow an ordinary method call (it does follow a method reference handed straight toEffect.create, one hop), and it cannot see words read inside an effect, put in a variable, and used outside it. It reads only browser-side modules, not server code and not tests. - The catalog is sent in full on every connection. No hash is exchanged first, so a browser that already has the words receives them again — about 12 KB for a three-hundred-string catalog, against a 4 MB message ceiling.
- A language switch made while the connection is down changes nothing on screen. The choice is written to the cookie and queued, and takes effect when the connection returns. The words come from the server.
- The machinery itself is not free, and every application pays for it. Translated languages cost
the browser nothing - they travel over the connection, and a build offering four languages is
byte-identical to one offering two, and removing the framework's own German leaves the file
byte-identical again — both measured. But the parts that receive them are compiled in
whether an application translates anything or not: the catalog store, the frame reader, the cookie
writer, the language signal and the framework's own English. Measured on the
todo-signalsexample, that is 26,561 bytes more to compile and 5,093 bytes more to download, gzipped — a 2.4 percent increase on a 208 KB baseline. It is not opt-in and there is no switch to turn it off. Anyone shipping to a slow connection should know the number without having to measure it.
Not planned at all, with the reasoning in the language support design: right-to-left layout, plural rules of any kind, locale-aware sorting and searching, translation tooling of any sort, per-tenant catalogs, translated route paths, and translating content stored in a database.
The annotation processor warns rather than fails for two footguns. Read your build output.
- A
@ClientWritablefield with no setter is skipped — its mutations are not tracked. @ClientWritablewithout@LiveSyncmeans mutations travel up but no state comes back down.
The client is written entirely in Java and compiled ahead-of-time by TeaVM. TeaVM has two backends,
JavaScript and WasmGC, and ZeroZ Stack uses the JavaScript backend today. Every example client module
sets <targetType>JAVASCRIPT</targetType>, and no module in the repository sets WEBASSEMBLY.
This is a deliberate interim choice, not a stale setting. TeaVM's WasmGC backend does not yet
provide functionality ZeroZ Stack depends on. WasmGC remains the intended destination — hence the
zerozstack-client module name — and the project will move to it once TeaVM's support is complete.
No application code changes with the backend: you write the same Java either way. The distinction matters only when describing what the build emits, so don't state that client code compiles to WebAssembly today.
- Only TeaVM-supported JDK APIs are available in client modules.
- Client code runs on a cooperative single-threaded scheduler.
java.lang.Threadexists and is what TeaVM calls a green thread — starting one re-enters TeaVM's scheduler and is the documented way to reach a context where a call may suspend. It buys no parallelism: nothing runs at the same time as anything else, and code that assumes real concurrency is wrong here. - A suspending call cannot start on a stack that began in native JavaScript. An RMI call inside a
DOM event handler, a
setTimeoutcallback, or a WebSocket frame handler fails with "suspension point reached from non-threading context". The router hits this on every navigation and handles it by running each navigation on a green thread; application code fetching from such a callback must do the same.
New in 0.8.0: every control in zerozstack-ui-components can be reached with Tab, pressed with
Enter, and says what it is, and KeyboardAndNamingContractTest fails the build when a new one
cannot. That is a floor, not a guarantee of a usable screen.
- The build check cannot tell you whether a name is a good name. It sees that a control has one. "Button" and "Delete this invoice permanently" both pass.
- It says nothing about color contrast, focus rings, motion, or whether the tab order makes sense. A control can satisfy every rule the check knows and still be unreadable on the theme it ships with, invisible when focused, or reached tenth when it should be reached first. Those need eyes, and this project has no automated answer to any of them.
- It reads source code, not a running page. It works out which element each listener was put on and what tag that element is. A keydown handler that answers Enter by doing nothing satisfies it.
- The real browser harness is not part of the build.
tools/ui-proofis where key presses are actually sent and the page is actually asked what it says — the only place a question like "is this error message part of the text a person can read" gets a true answer. It compiles the library to JavaScript, takes about a minute, needs a Chrome on the machine, and is run by hand. Nothing runs it for you, so a change that breaks it can be merged without anybody noticing until somebody runs it. - No accessibility rule is enforced in your application. Every check described here reads this repository's own source. A screen you write in your own project is checked by nothing.
replaceContentsis not enforced outside this repository either. The check that fails a build for emptying a container by hand reads the files in this checkout. In your application, writinggetElement().setInnerHTML("")still silently leaves the old screen's timers and effects running.- A dialog beats every layer, and only a dialog can cover a dialog.
Layerorders everything drawn on the page, but an open modalDialogsits in a place of the browser's own above the whole page that no stacking number reaches. Inside that place things stack in the order they arrived, so a dialog opened after the framework's "connection lost" bar is drawn over it. - On a browser older than Chrome 114, Safari 17 or Firefox 125 the "connection lost" bar has nowhere to go, and behaves as it did before 0.8.0: visible everywhere except under a dialog.
- A tooltip is drawn on the side it was told to sit on. One placed against the right-hand edge of the window is still partly off the window. It no longer takes the whole page sideways, which is what it did before 0.8.0.
- Loaders run in sequence, not in parallel. A layout's loader and its child's cannot overlap, because of the single-threaded scheduler above. The guarantee routing gives is ordering — data before render, shared data fetched once in a layout — not concurrency.
- The whole chain is rebuilt on every navigation. A layout is not kept mounted while its children swap, so moving between two children of the same layout re-runs that layout's loader and rebuilds its components.
- One child per layout. Sibling outlets are not modeled.
- No wildcard or optional segments. Patterns are literal segments and
:paramswith a fixed count;/files/*pathis not supported. - No lazy loading, transitions or scroll restoration. Everything is in one bundle and the container's contents are replaced outright.
- Route guards are client-side only.
@RequiresRoledecides what to show; the server re-checks every call, and that is what protects data.
- Installing does not make an application work offline, and is not intended to. Every view loads
its data over the WebSocket, signals get their retained values from the server on subscribe, and
LiveSync objects live server-side. There is no client-side store, so with no connection there is
nothing to render. Opened offline, an application shows
/zeroz4j-offline.htmland stops there. This is a property of the architecture. Do not read the presence of a service worker as a promise of offline operation. - The service worker caches the shell only — the client bundle and the offline page. No data, and no application assets beyond what a page happens to request.
- Its caching strategy is fixed. Navigations are network-first, same-origin assets are
cache-first,
/wasm-rmiand cross-origin requests are never intercepted. An application needing different behavior registers its own worker withPwa.install(path)and takes on the cache-invalidation problem the shipped one solves. - No background sync and no queued writes. An action taken with no connection is lost, not replayed later.
- Push delivery is not implemented. The framework collects a subscription; posting to it needs a signed VAPID JWT and RFC 8291 payload encryption, which is a library's job. Subscription lifecycle — deleting one after a 404 or 410 from the push service — is the application's.
- No icon generation. Applications supply their own PNGs at the sizes browsers want, including a maskable one.
- Installation and push need a secure origin.
http://localhostcounts; any other host needs HTTPS, and browsers offer neither without it.
- One handler per application. If more than one
FileUploadHandleris deployed, the framework uses one of them and logs a warning naming them all. - No resume and no chunking. A file arrives in one request. An upload that was canceled, or whose connection dropped, starts again from the beginning next time.
- No per-user, per-tenant or per-day quota, and no limit on how many files may be uploaded at
once.
zeroz.upload.maxBytesis 25 MB and applies to one file. Anything else — who may upload, how often, how much in total — the application decides for itself fromgetPrincipal(),getRoles()andgetTenantId(). - No virus scanning and no content checking. The framework never looks inside the file.
getContentType()is the browser's guess from the file extension, andgetFileName()is whatever the browser reported; both are text, and neither is used for anything by the framework. - The temporary file is deleted the moment the handler returns, whether it returned a result or
threw. Move or copy it inside the method; keeping the
Pathand reading it later finds nothing. - The handler runs on the upload request's thread, so a slow handler holds that request open. Hand long work to a background thread and return quickly.
- An upload needs a live connection. The page asks its existing WebSocket for a one-time pass,
valid for 60 seconds (
zeroz.upload.passSeconds), usable once, and only from the browser it was issued to. There is no way to upload without a connection open — no API key, no signed URL. - A file that did not arrive whole never reaches the handler. A canceled upload, or one whose connection dropped, is answered "That file did not finish sending. Please try again." and the part-received file is deleted.
- Messages are whole, never partial.
@OnMessagetakes a completeByteBuffer; there is no partial-message handling and no chunking. A message larger than the limit does not raise an error and never reaches framework code — it closes the socket. There is nothing to catch and nothing sent back. The client reconnects by itself, so the symptom is a connection that drops whenever one particular call is made. - Messages are capped at 4 MB by default.
zeroz.ws.maxBinaryMessageBytessets the largest binary message the endpoint accepts; unset, the framework applies 4,194,304 bytes, the same default gRPC uses. An explicit setting wins in either direction. The limit in force is logged once at startup, naming the property. Raise it if a real response needs more, or return less — page the results, or return identifiers and fetch details on demand. - The RMI connection is not an upload channel. It carries the messages an application exchanges, not documents, images or video. A file over the limit closes the connection. File upload is a separate feature.
- The idle timeout is still the container's.
zeroz.ws.idleTimeoutMinutesis unset by default, so without setting it an abandoned browser tab holds a session and its server-side resources for as long as the container allows. - A connection that stops reading is closed, not waited for. The server can only send as fast as
the browser accepts, so messages for a browser that has stopped accepting are held in a queue for
that one connection. The queue holds 256 messages or 8 MB, whichever comes first
(
zeroz.ws.maxPendingFramesPerSession,zeroz.ws.maxPendingBytesPerSession); an empty queue always accepts the next message however large it is, so a single big response is never refused. Past that the connection is closed with WebSocket code1013, and the log names the limit that was hit. Such a browser has already missed messages it will never see, so it has to reconnect and fetch a fresh copy either way; holding more would let one browser use up the server's memory. Nothing else waits for it: each connection has its own queue and its own thread, so a stalled browser delays only its own messages, never another browser's and never a broadcast. - Wire lengths are checked before anything is allocated. Every length and element count in the
binary format is a number the sender chose. Each one is compared against the bytes actually
present, at the width of the element it describes, before an array or a collection is created, and
a negative one is refused with a readable message rather than escaping as a
NegativeArraySizeException. Collections grow as items arrive rather than being sized from a claimed count, and nesting is capped at 256 levels. A damaged message therefore fails at once with an explanation instead of reserving memory or running out of stack. Applications see this only as a clearer exception on a corrupt stream. - Container-managed threads are platform threads. A Jakarta EE 10
ManagedThreadFactorycannot produce virtual threads, so a WAR deployment supplying one throughSessionThreadFactoryProvidertrades cheap threads for the container's context — naming, transactions and the caller's identity. Without such a provider, RMI calls run on framework-created virtual threads that carry none of that, and ajava:comp/env/…lookup inside a service fails. - The framework does not verify what a container's factory carries. Its contract is only that calls are dispatched on threads the supplied factory produced; whether those threads have the container's context is the container's contract, and worth an integration test in the application.
zerozstack-server-jaxrsis a catch-all at/. Do not add it to a WAR that has its own servlets.zerozstack-server-corecarries no JAX-RS or servlet type at all, which is what makes it safe inside somebody else's deployment.Zeroz4jShellServletis not auto-mapped. Deliberately: mapping it at/from inside the framework would reintroduce the collision the module split exists to prevent. The deployment declares the mapping.- Mapped at
/, the shell servlet replaces the container's default servlet. Nothing else serves static files after that, so it serves them: the classpath under/META-INF/resources/first, then the WAR's own web content through theServletContext.WEB-INFandMETA-INFare never served from the archive root. A file present in both places is served from the classpath. - A context path is handled, but only for what the framework owns. The shell is served with a
<base href>for the deployment's context path, and the router,Pwa.install()andZeroz4jClient.defaultWebSocketUrl()all read the application's root from it. Anything an application writes with a leading slash — anhref, afetch, a redirect, a cookiePath, a hand-built WebSocket URL — still escapes the context path, and does so silently until deployed. Build those withAppBase.location(...)/AppBase.url(...). - The
<base href>is skipped when the shell already declares one, and when it has no<head>. Both are deliberate, and both mean an application that does either owns the problem itself. - Closing a connection does not wait for the message it interrupted. Everything the connection had queued is thrown away and the message being handled is interrupted, but the close returns straight away rather than waiting for that thread to stop. So a service method can still be running for a moment after the connection it belongs to is gone, and a shutdown that closes connections can reach the end of the container's own teardown while one is still finishing. The framework no longer lets that thread die noisily, and a call that fails is still answered on its own; what an application must not assume is that nothing of a connection's is running once it has been told the connection closed. Waiting instead is not an option: a message may legitimately be waiting up to thirty seconds for a lock, and the close runs on the thread the container reads every connection with.
README describes multi-tenancy as available out of the box. Be precise about where it exists:
- Storage — isolated by
TenantResolverand the EclipseStoreTenantStorageProvider. - Server events and LiveSync — isolated when published with
Scope.TENANT, which requires anAuthenticationProviderthat reports a tenant.publish(topic, payload)with no scope still reaches every connected session. - Signals —
Signals.scoped(name, initialValue, Scope.TENANT)holds one value per tenant.Signals.shared(...)is a single global value by definition and crosses every boundary. - Not isolated: the
ObjectMapperhandle namespace is shared across tenants, and scoped signals keep every target's value in memory for the process lifetime with no eviction. The handle namespace being shared is not a leak — a handle is a random 36-character identifier and being sent an object is what earns the right to ask for it back — but it is one namespace, not one per tenant.
Nothing here is automatic: a tenant-scoped push is a scope you pass. GLOBAL — which is also what
you get by leaving the scope off — sends to every connected session, whichever tenant it belongs to.
- One example uses
@ClientWritable— the topic box inchat-livesync, which is the only place the up direction of LiveSync is shown end to end. It was added in 0.8.0, along with the fix for the up direction being broken. components-showcasepublishes to push topics that nothing subscribes to, using the low-levelbroadcastPush(String, Object)rather than a typedEventTopic. Do not copy that pattern.
Several documents predating 0.4.0 contain stale API claims and are being rewritten. Where a document
and the source disagree, the source is correct — please open an issue. docs/GETTING_STARTED.md,
docs/CODE_WALKTHROUGH.md, docs/ARCHITECTURE.md and docs/CONCEPTS.md carry warning banners naming
their specific known errors.