Architecture reference
How RCWeb works
RCWeb serves browser apps over HTTP and relays executable JavaScript between browsers in the same virtual room over WebSockets. Browsers own application behaviour; the Java server owns connections, room membership and message routing.
- Browser
- Owns application state
- Java relay
- Routes within a room
- WebSocket
- Carries JavaScript
Reflects the current implementation and defaults. Capacity depends on the workload and deployment.
System boundaries and state
#
View full size
The relay does not interpret game moves, merge documents or store an authoritative app snapshot. It does retain operational state. Calling the whole backend “stateless” obscures both reconnect behaviour and the constraints on running multiple server instances.
| Owner | Responsibilities and lifetime |
|---|---|
| Browser application | UI, game rules, shared data, authority selection and conflict resolution. Persistence is app-specific; in-memory state disappears when its owning page closes or reloads. |
| Browser runtime | Room/client identifiers, the socket, reconnect timers and a bounded outgoing queue. This is transport support, not a shared-state database. |
| Java relay | In-memory rooms, client slots, request metadata, sockets and pending commands. A server restart loses this state. |
| Supporting services | Resource/proxy caches, monitoring data, pending file chunks and MCP result callbacks have their own lifetimes. These are separate from application state. |
HTTP serving and Java components
#Main loads settings, configures certificate handling, installs RootResponder
and starts HttpServer. Each accepted TCP connection gets an HttpHandler
on a virtual thread. The handler uses SocketSniffer to select plain or TLS transport,
then reads requests through a SuperSocket.
HttpHandler parses HttpRequest, calls the responder and writes the resulting
HttpResponse. It can handle multiple HTTP requests on a keep-alive connection.
A WebSocket upgrade transfers the connection to WebSocketResponse for its live read/write loops.
| Component | Role and relationships |
|---|---|
RootResponder | Routes static files, app setup, WebSocket upgrades and service endpoints.
Uses ResponseCache for resources and delegates specialised requests to their handlers. |
RoomManager | Process-local singleton registry: a HashMap protected by a
ReentrantLock. Atomically looks up/creates a room and attaches a client; runs background maintenance. |
Room | Owns client membership, target selection and broadcast.
Uses a CopyOnWriteArrayList with a membership lock for attachment and expiry. |
Client | A reusable slot identified by client ID within a room. Holds the current request, direct-send handler and fallback command queue. |
ClientRequest | One connection's room, app, client ID, IP address, user agent, start/end times and connection reference. A reconnect replaces the request on a retained slot. |
ClientWebSocketHandler | Validates the app connection request, attaches the client and supplies the WebSocket response callbacks for incoming commands, outgoing updates and closure. |
ClientCommand | Allow/deny target lists plus JavaScript bytes in a
ReusableBufferList. Parses the routing header; does not evaluate the payload on the server. |
Resources and URL mapping
An app URL such as /chat/ resolves to /app/chat/index.html in the resource sources.
RootResponder checks the classpath first, followed by site.zip,
apps.zip and immutable.zip. During development, the source directories can be placed
directly on the classpath.
src/main/apps/app/<name>/: app HTML, CSS, JavaScript and user-facingappinfo.md.src/main/immutable/: fixed resources, including assets served under app URLs.src/main/site/: documentation, site pages and the MCP browser interface.src/main/resources/assets/core/: shared communication runtime and setup template, served under/assets/core/.
ZIP sources have different cache policies: site content gets a ten-minute cache lifetime; immutable
content gets a one-year immutable policy. Development refresh behaviour also depends on
resourceCacheEnabled. App and documentation edits do not require a Java compile.
Browser connection lifecycle
#- Load the app. For example, open
/chat/?r=test-0001in two tabs. Load/assets/core/comms.jsbefore the app script. - Register handlers. Expose remote functions globally and install hooks such as
rc.onConnectedbefore callingrc.connect(). - Fetch setup. The runtime reads
rfrom the URL, falling back tolocalStorage.rcwebRoomwhen the parameter is absent. It injects a relativesetup.jsscript request. The server preserves a valid room ID or generates a new one, and generates a fresh client ID. - Open the socket. Setup assigns
rc.version,rc.app,rc.room,rc.clientandrc.commsWebSocket, then callsrc.listen(). The endpoint is/<app>/app.websocket?r=<room>&c=<client>, usingwsorwssaccording to the setup request's transport. - Attach and initialise. The server attaches a client slot and upgrades the connection.
On socket open, the runtime calls
rc.onConnected(), then flushes its outgoing queue. Use this repeatable hook to request current app state and rebuild room-specific UI. - Recover after interruption. The runtime reconnects using the existing setup and client ID. A full page load fetches new setup and therefore gets a new client ID; it is not a durable player identity.
Valid room IDs contain 4–11 ASCII letters, digits or hyphens; generated IDs use the
xxxx-xxxx format. Client IDs contain exactly eight characters from the same accepted character
classes. Room lookup is case-sensitive. Invalid room IDs at setup are replaced, while invalid room/client IDs
on the WebSocket request are rejected.
Message format and routing
#rc.send(js, target) sends a WebSocket text message containing a selector line, a CRLF,
then executable JavaScript. ClientCommand.readRaw() splits the header from the body;
Room.broadcast() selects matching clients in that room.
chat,!abcd2345
chat.receiveMessage("efgh6789", "Hello from another browser");In this example, every chat client except abcd2345 is selected. The selector
header is not sent to recipients; the JavaScript is delivered with a server version-update prefix.
| Selector | Matches |
|---|---|
chat | An exact app name. |
abcd2345 | An exact client ID. |
* | Every client, including the sender if it matches. |
chat,notepad | Either app. |
spacewar* / *-c | App-name prefix / suffix matching; these are not general glob patterns. |
chat,!abcd2345 | Chat clients except the listed client. |
!c,*-c | All clients except app c and apps ending in -c. |
There is one ! separator: everything after it belongs to the deny list, which overrides
the allow list. An empty allow list means all clients are initially eligible. The relay does not
automatically exclude the source or attach an authenticated sender identity to the payload.
For ordinary remote function calls, use the serialization helper with an application-controlled function name:
// The receiving app defines chat.receiveMessage globally.
// Run after connection; include the sender ID if the app needs it.
rc.sendFunctionCall('chat', 'chat.receiveMessage', rc.client, 'Hello');The helper JSON-serializes arguments. The receiver's rc.runJavaScriptUpdate() executes the
resulting source with window.eval and calls its local success/error hook. Serialization avoids
hand-built argument escaping; it does not make the execution channel a sandbox.
Delivery, threads and queues
#WebSocketResponse starts a virtual reader thread; the existing HTTP handler thread becomes
the writer. Incoming text calls ClientCommand.readRaw() and then Room.broadcast().

View full size
- Direct delivery: for each selected client,
Client.sendCommand()first invokes its direct-send handler. A successful write bypasses the queue. - Fallback delivery: if no handler exists or it throws, the command is appended to the client's queue. Membership notifications explicitly use this queue.
- Queued writer:
Client.awaitCommand()waits using aReentrantLockandCondition. Enqueue, closure and request replacement wake it. Socket writes happen after the command lock is released. - Idle updates: after a 29.5-second queue wait without a command, the writer emits
rc.onNoUpdate(). Outgoing commands carryrc.onUpdate(version); the default browser hook reloads the page on a version mismatch.
The WebSocket write lock prevents concurrent writers from interleaving frame bytes. It does not establish an application-wide ordering across senders or between direct writes and queued commands. Direct sends also run in the calling thread, so a slow recipient can delay that broadcast's remaining writes.
There are two separate queues. The browser's offline send queue defaults to 200 messages and drops the
oldest when full; it flushes after rc.onConnected(). The server's per-client fallback queue
currently has no explicit size bound. Neither survives the process/page that owns it restarting.
Room lifecycle and limits
#RoomManager.attachClient() holds the registry lock while looking up or creating a room and
attaching its client. The lock order is registry then room membership. This prevents eviction between
lookup and attachment; maintenance sends membership notifications outside the registry lock.
A reconnect with the same ID reuses a retained client slot, replaces its request and wakes the old writer. A late close of the old request does not end the replacement. Once maintenance removes the slot, its queued commands are no longer available to a reconnecting browser.
| Limit or interval | Behaviour |
|---|---|
| 32 client slots per room | Room.MAX_CLIENTS includes retained reconnect slots.
An existing ID can reattach at capacity. A new ID receives a room-full message and WebSocket close code 1008;
the supplied message stops automatic retries and asks the user to reload after freeing space. |
| 1-second maintenance cycle | When backgroundMaintenance is enabled,
the manager cleans membership and checks empty rooms. |
| 500 ms reconnect grace | Ended requests are retained until a maintenance pass finds them older than the cutoff. Removal is not an exact 500 ms timer. Open requests are not evicted just for being idle. |
| 60-second empty-room retention | After the last slot is removed, a room remains in the registry until the retention period elapses and maintenance evicts it. |
| 5-second browser connect timeout | The runtime times out the opening attempt and schedules a retry. Retry delays have a 1-second minimum. |
| 1 MiB frame/message defaults | Settings.websocketMaxFrameSize and
websocketMaxMessageSize bound inbound WebSocket data. These are configurable transport limits,
not recommended app message sizes. |
Membership changes enqueue rc.onUpdateClients([...]) with client IDs. This view can include
recently disconnected slots during the grace period. MCP's list_clients instead filters for
currently open connections and includes app names. On JVM shutdown, Main asks the manager to
close room sockets, then stops the HTTP server.
Application state and recovery
#
View full size
Asymmetric apps: viewer and controllers
The viewer owns authoritative state; phone controllers send intents and receive snapshots or replies. The viewer validates actions and decides their order. Supporting multiple viewers requires an explicit authority rule. Controller handoff, room-preserving navigation and the standard QR invitation panel are application responsibilities, described in the app implementation guide.
Symmetric apps: collaborating peers
Peers each maintain a local copy. On join or reconnect, request a snapshot and apply a deterministic revision/merge rule so a delayed reply cannot overwrite newer edits. Resolve concurrent changes in app code; broadcasting updates alone does not provide consistency.
Design for interruptions
- Separate player identity from
rc.client, which changes on a fresh page load. - Make
rc.onConnected()repeatable and handle late joins as well as reconnects. - Use event IDs, revisions or session IDs to reject duplicate and stale actions, including sender echoes.
- Disable time-sensitive controls while disconnected, or reject expired input after the outgoing queue flushes.
- Choose app-specific persistence when state must survive every browser closing or the authoritative viewer reloading.
A server restart discards relay state. Browsers may reconnect with their existing in-memory state, but the server cannot reconstruct a game or document for them. A server version change can also trigger browser reloads, so recovery cannot depend solely on pages staying open.
Files, WebRTC, MCP and monitoring
#RootResponder hosts several paths alongside the app relay. Their data flows and guarantees differ.
- Browser-hosted files: a request to
/x-file/<room>/<client>/<fileId>/<filename>is handled byClientFileProxy. The server asks the hosting browser for data throughrc.sendFileChunk(); that browser uploads chunks to/x-chunk/...over HTTP. The proxy streams the response to the downloader. The host must retain the File/Blob and remain available; this is not durable server file storage. - WebRTC: apps can exchange signalling through RCWeb, then use WebRTC for media or data. Media follows the WebRTC connection rather than the JavaScript relay; connectivity depends on the app's peer-connection configuration and the network.
- MCP:
McpHandlerserves/api/mcp, exposinglist_clientsandexecute_javascript. Execution targets one exact live client, writes once without offline queuing or retries, and waits for a token-correlated HTTP callback at/api/mcp-result. The default wait is 10 seconds, with a 30-second maximum. A timeout leaves execution outcome unknown and does not cancel browser code. - HTTP proxies:
/api/proxy/,/api/basic-proxy/and/api/image-proxy/route outbound HTTP requests through specialised proxy handlers.ApiProxyhandles named APIs;CachingProxysupports cached upstream responses. Their caching and request policies are separate from room delivery. - Observability:
CommunicationsInterceptorfeeds HTTP and WebSocket monitors. Health checks and live log streams are exposed under/api/. Monitoring state describes the transport and server, not an authoritative app snapshot.
See the endpoint reference for request details and MCP integration guide for the external tool interface.
Trust, deployment and performance
#Room access is a trust boundary
Room codes provide access to cooperating browsers. App names and client IDs are routing identifiers, not account authentication or role permissions. A participant able to send JavaScript into a room can execute code in matching pages; the receiver runs it in the page's context. Treat room links and QR codes as access information, and use this model only where participants are trusted to control those pages.
Use HTTPS/WSS for transport protection. It does not isolate participants from each other or make relayed
code safe. Validate app-level arguments and render plain user text with textContent, while
recognising that these checks do not constrain a peer that sends arbitrary JavaScript.
One room must reach one relay instance
The registry and queues are local to one JVM; the current room relay has no cross-instance replication. If deploying multiple instances, all clients of a room need routing to the same instance. Independent per-browser session affinity is insufficient. File requests and MCP operations must also reach the instance holding the target connection and any pending callback state.
A reverse proxy must support WebSocket upgrades and long-lived connections. Account for the generated
socket URL: setup derives its host from the HTTP Host header and its scheme from whether the
server-side socket is secure. Verify those values when terminating TLS upstream.
Measure capacity instead of assuming it
Virtual threads support blocking connection handling without allocating an operating-system thread for every connection. They do not remove socket buffers, TLS work, payload memory, queue growth or network limits. Routing scans room clients, and each broadcast writes to every selected recipient, so message size, frequency and fan-out determine much of the cost.
Throttle continuous controls, send compact state updates and keep large files off the JavaScript channel. Measure latency, heap, pending work and connection failures with realistic recipients and slow devices. The unbounded server fallback queue and direct writes to slow clients deserve particular attention in load testing; this architecture alone does not establish a supported connection count.
Source map and further reading
#Find the implementation by responsibility. Repository paths show where to work; API links open the corresponding Javadoc.
-
Server
Startup and request routing.
-
Rooms
Room lifecycle, client membership and command routing.
-
Client communication
Connection requests, commands, WebSocket handlers, generated JavaScript and file proxying.
Repositorysrc/main/java/net/targetr/rc/client/Java API -
Network
Sockets, protocol detection and TLS connections.
Repositorysrc/main/java/net/targetr/wtm3/net/Java API -
HTTP
Request handling, response generation and WebSocket framing.
Repositorysrc/main/java/net/targetr/wtm3/net/http/Java API -
Resources
Resource sources and response caching.
-
Configuration
Server settings and defaults.
Repositorysrc/main/java/net/targetr/wtm3/Settings.javasrc/main/java/net/targetr/wtm3/net/tls/acme/AcmeSettings.javaJava API -
Browser runtime
Browser lifecycle, selectors, JavaScript execution and connection setup.
Repositorysrc/main/resources/assets/core/comms.jssrc/main/resources/assets/core/setup.template.jsRuntime reference -
Integrations
External tools and result callbacks.
-
Utilities
Identifier generation, HTTP response helpers and external API routing.
Repositorysrc/main/java/net/targetr/rc/util/Java API -
Monitoring
Health checks and logging.
Repositorysrc/main/java/net/targetr/rc/monitoring/Java API -
Tests
Room behaviour, command delivery, functional communication and HTTP transport.
Room capacity, expiry, maintenance and deliverysrc/test/java/net/targetr/rc/HTTP transportsrc/test/java/net/targetr/wtm3/net/http/
Further reading
Browse the complete Java API reference for class and method details.
For implementation recipes and runtime APIs, start with the app development guide. For service contracts, use the
endpoint reference. For an individual app, consult its appinfo.md for usage and its
local AGENTS.md for implementation details.