A controller turns HTTP into method calls. This chapter covers how requests are mapped to methods and how their parts are bound to parameters, how a return value becomes a response, how the app and the server share one declaration of their API, and how a server holds WebSocket connections open. A first server introduced the shape; this is the detail.
Mapping requests
A @RestController is a class whose methods answer requests. It’s also a bean,
so its constructor receives whatever it depends on, as Backend beans and dependency injection
describes. Each mapped method names a verb and a path:
@RestController
@RequestMapping("/api/products")
public class Products {
private final Map<Long, Map<String, Object>> products =
new LinkedHashMap<Long, Map<String, Object>>();
@GetMapping("/{id}") // GET /api/products/42
public synchronized Map<String, Object> get(@PathVariable("id") long id) {
return products.get(Long.valueOf(id)); // null answers 404
}
@GetMapping("/search") // GET /api/products/search?q=mug
public synchronized List<Map<String, Object>> search(
@RequestParam(value = "q", required = false) String query,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
for (Map<String, Object> p : products.values()) {
if (out.size() < limit
&& (query == null || String.valueOf(p.get("name")).contains(query))) {
out.add(p);
}
}
return out;
}
@PostMapping // POST /api/products
@ResponseStatus(201)
public synchronized Map<String, Object> create(
@RequestBody Map<String, Object> body,
@RequestHeader(value = "Idempotency-Key", required = false) String key) {
Long id = Long.valueOf(products.size() + 1);
Map<String, Object> product = new LinkedHashMap<String, Object>(body);
product.put("id", id);
products.put(id, product);
return product;
}
@DeleteMapping("/{id}") // void answers 204
public synchronized void delete(@PathVariable("id") long id) {
products.remove(Long.valueOf(id));
}
@GetMapping("/{id}/label")
public HttpServer.Response label(HttpServer.Request request, @PathVariable("id") long id) {
byte[] text = ("product " + id).getBytes();
return request.respond(200, "text/plain; charset=utf-8", text);
}
}
@GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping
cover the usual verbs, and @RequestMapping(value = "/x", method = "OPTIONS")
covers the rest. On the class, @RequestMapping is a prefix for every mapping
inside it, and a mapping with no path answers on the prefix itself, as create
does above. A mapping may list several paths, which is how a route keeps an old
URL working.
A {name} segment matches one path segment and is bound with @PathVariable.
Within one controller a literal route is tried before a route with variables that
would also match it, so /api/products/search above reaches search rather than
get with an id of search.
What the build refuses is a route nothing could ever reach. Two methods answering the same verb and path shape are an error, even when their variables are named differently, because a variable’s name isn’t part of what a request carries. The same is true across controllers: a variable route in one controller that would answer a literal route of another is reported, since the routers are tried in turn and the literal one would never run.
Binding the parts of a request
Every parameter of a mapped method says where its value comes from, with exactly one annotation:
| Annotation | Reads | Binds to |
|---|---|---|
| a |
|
| a query parameter, or a field of a form body |
|
| one part of a |
|
| a request header |
|
| the body |
|
none, typed | the request itself | anything the request exposes |
The name is required. Java drops parameter names when it compiles unless it’s
told to keep them, and a router that guessed would bind the wrong value in
silence, so @RequestParam("q") names the parameter the client sends rather than
the variable in the method. A parameter with no annotation, or with two, is a
build error: one parameter reads from one place, which matters most for an input
like a credential that could otherwise arrive from a query string when the code
meant a header.
@RequestParam, @RequestHeader and @RequestBody are required unless they say
otherwise. A request that leaves out a required value is answered 400, with the
value’s name in the body, before the method runs. defaultValue supplies a value
for one that’s missing. Two combinations are refused at build time, because each
would hand the method a value nobody sent: required = false on a primitive with
no defaultValue, since an int can’t hold "absent" and would arrive as 0;
and a defaultValue that isn’t a value of the parameter’s type.
A body is parsed as JSON. A String, Map or List parameter gets it as parsed;
a class of your own — an entity, a DTO — or a List<Order> of them is filled in
by a codec the build writes for that class, as Jackson fills one in for a Spring
controller. Your own classes as JSON describes the form.
A request target with a malformed escape sequence, or one that isn’t valid UTF-8, is answered 400 before any route is tried, rather than as a 404 the client couldn’t explain.
What a method returns
| Return type | Response |
|---|---|
| 204 with no body, or the |
| The text, as |
A | JSON, with 200 or the |
| Exactly what the method built. |
| 404. |
Any other return type is a build error: a JDK class with no JSON form, such as
java.io.File, would otherwise come out as the JSON string of its toString(),
with the build and the request both reporting success. @ResponseStatus takes a
final status between 200 and 599.
A method that returns its own type can’t return a refusal, because its return type is the body of a success. It throws one:
if (taken(email)) {
throw new ResponseStatusException(409, "That e-mail already has an account");
}
The reason is sent as the body, in plain text, so keep out of it anything the
caller shouldn’t learn. A status below 500 is an answer: it isn’t logged and
isn’t counted as a failed request. A status of 500 or above is reported the way
any other failure of the handler is, and answered with the status given. The
exception is unchecked, so inside a @Transactional method it rolls the
transaction back.
Your own classes as JSON
A method can return an entity or any class of your own, or take one as its body, and the build writes the JSON codec for that class and for every class its fields reach. There is no reflection at run time: the codec is ordinary code that reads and writes each field by name, so it costs what the hand-written version would.
public class Order {
public long id;
public String customer;
@JsonProperty("placed_at")
public Date placedAt;
public List<OrderLine> lines = new ArrayList<OrderLine>();
}
public class OrderLine {
public String sku;
public int quantity;
}
@RestController
@RequestMapping("/orders")
public class OrdersApi {
private final Map<Long, Order> orders =
Collections.synchronizedMap(new LinkedHashMap<Long, Order>());
private final AtomicLong ids = new AtomicLong();
@PostMapping
public Order place(@RequestBody Order order) {
order.id = ids.incrementAndGet();
order.placedAt = new Date();
orders.put(Long.valueOf(order.id), order);
return order;
}
@GetMapping("/{id}")
public Order find(@PathVariable("id") long id) {
return orders.get(Long.valueOf(id)); // null is a 404
}
}
A POST /orders with {"customer":"Ada","lines":[{"sku":"A-1","quantity":2}]}
answers with the stored order:
{"id":1,"customer":"Ada","placed_at":1740821400000,"lines":[{"sku":"A-1","quantity":2}]}The JSON form is the one the app’s @Mapped mapper uses, so a class shared by the
app and the server reads the same on both sides:
The fields are the class’s own and its superclasses', except static and
transientones. A public field is used directly; any other through itsgetX(orisX) andsetXmethods, and is left out when it has neither.@JsonProperty("due_at")renames a field and@JsonIgnoreleaves one out, both fromcom.codename1.annotations.A
Dateis written as milliseconds since the epoch, which is Jackson’s default and what the app’s mapper reads, and is read from that or from an ISO-8601 date such as2025-03-01T09:30:00Z. Abyte[]is base64, an enum is its name.A subclass is written with its own fields, even where a method declares the superclass.
A field declared
Object, or a rawMaporList, is written by what it holds when the server runs. A value of a class the build writes a codec for goes through that codec; a value of any other class of yours is answered 500 rather than written as itstoString(), so declare the field with its type.A body’s unknown members are ignored and absent ones leave the field as the constructor set it, as a Spring Boot application does.
A body the codec can’t read — a string where a number belongs, an enum name that doesn’t exist — is answered 400 with where it was and what was expected:
$.items[2].due: expected a number or an ISO-8601 date, got true
Two objects that refer to each other, such as an order whose lines point back at
it, would be written forever, so a response that nests more than 64 objects deep
is answered 500 with a message naming the class; mark the field that points back
@JsonIgnore. The build refuses what it can’t write a codec for, and says why: a
field typed by a type variable, such as List<T> in a generic Page<T>; a body
class with no constructor without arguments; an interface; and an array other
than byte[].
A method that throws is answered 500 with the body internal error, and the
exception is logged. The message isn’t sent to the client, because an exception
message is the most common way a server leaks its internals. To answer a failure
with a status and a body of your choosing, return an HttpServer.Response, built
with request.respond(status, contentType, bytes) or
request.respondJson(status, value).
Taking the request itself
A parameter typed HttpServer.Request receives the request, which is the way to
anything the binding annotations don’t model: every header, the raw body, the
session, or a response with headers of its own. The label method in the example
above takes it for that reason.
A request’s byte arrays belong to the connection and are reused by the next request on it, so a handler that keeps the body beyond its own call copies what it needs. That’s the price of a router that allocates nothing for a route without path variables.
Forms and file uploads
A browser form posts its fields as application/x-www-form-urlencoded, or as
multipart/form-data when it carries a file. @RequestParam reads a field of
either kind of form the way it reads a query parameter, so a handler doesn’t
care how the client encoded it. The query string wins when both have the name,
as it does in a servlet container. @RequestPart binds one part of a multipart
body, which is the way to a file:
@PostMapping("/avatars")
public Map<String, Object> upload(@RequestPart("image") HttpServer.Part image,
@RequestParam("caption") String caption) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("file", image.getFilename());
out.put("type", image.getContentType());
out.put("bytes", image.getSize());
out.put("caption", caption);
return out;
}
An HttpServer.Part has the part’s name, its file name when it’s a file, its
content type, its own headers and its bytes. A body that isn’t well-formed
multipart is answered 400 before the method runs, and so is a request without a
required part. A handler that takes the request itself reads the same parts with
getParts() and getPart(name), and reads a field with param(name).
The body limit applies to the whole multipart request, files included: 8 MB, as
for any other body. A body that isn’t text — an upload, an image,
application/octet-stream — reaches the handler as bytes through
getBodyBytes(). getBody() returns text only for a text type, JSON, XML or a
form, after checking that it’s valid UTF-8, and refuses a binary body rather
than hand the handler garbled characters.
Compression
cn1.server.compression.enabled=true turns on gzip for responses, with the two
other settings Spring Boot has for it. A response is compressed when the client
sends Accept-Encoding: gzip, its type is in cn1.server.compression.mimeTypes
(text, JSON, JavaScript and XML by default), and its body is at least
cn1.server.compression.minResponseSize bytes (2048 by default). A static file
is left unchanged, because it’s sent straight from the page cache, and so is a
response whose handler set a Content-Encoding of its own. A range answer (206 or any
response with Content-Range) is never compressed either, because its offsets
describe the uncompressed bytes, and neither is a response with a strong ETag,
which names its exact bytes, as Tomcat leaves one by default. Compression is off
by default because it trades processor time for bytes, and a server behind a
proxy that already compresses would pay for it twice.
A request body sent with Content-Encoding: gzip is decompressed before the
handler sees it, whether responses are compressed or not. It’s held to the same
8 MB limit after decompression, so a small compressed body can’t expand past it.
A request in any other encoding is answered 415.
Cross-origin requests
A browser refuses to let a page read a response from another origin — another
scheme, host or port — unless the server says it may. A web app served from one
host that calls an API on another needs the API to say so, and the cn1.cors
settings do:
cn1.cors.allowedOrigins=https://app.example.com
cn1.cors.allowCredentials=true
cn1.cors.exposedHeaders=X-Request-Id
| Key | What it sets |
|---|---|
| The origins that may call the server, separated by commas, or |
| The methods a cross-origin request may use, preflighted or not. All the common ones by default. |
| The request headers a preflight allows, |
| Response headers beyond the basic ones that the page may read. |
| Whether the browser sends cookies and the page may read the answer. A browser
refuses this with a |
| How long a browser may cache a preflight answer, 1800 seconds by default. |
The server answers a preflight OPTIONS request itself, and adds the headers to
every response to an allowed origin. A cross-origin request from an origin not on
the list, or with a method not on it, is refused with a 403 before any handler
runs, as Spring refuses it; a request whose Origin is the server itself isn’t a
cross-origin request. A
handler that maps OPTIONS or sets Access-Control-Allow-Origin itself keeps its
own policy, which is how the MCP endpoint keeps the stricter one it has.
Serving static files
cn1.static.root names a directory, and the server answers requests under
cn1.static.prefix (/static by default) with its files, after every route has
had its chance. A directory is answered with its cn1.static.index file, and every
file carries cn1.static.cacheControl. The resolved path must be inside the root,
checked by resolving it on disk rather than by inspecting the request, so ../,
an encoded escape sequence and a symbolic link out of the tree are all refused.
Conditional requests and ranges are honoured, and where the platform supports it
the file is sent from the page cache to the socket without passing through the
process.
Hosting the app in the browser
A project with an app and a server can have the server host the app’s browser build. One address is then both the API and the app: somebody opens it and is using the app, with nothing to install.
mvn -pl backend -Dcodename1.platform=backend cn1:backend-webapp (1)
mvn -pl backend -am -Dcodename1.platform=backend cn1:backend (2)Builds the app for the browser and stages it in
backend/target/webapp.Runs the server, which now answers
/with the app.
cn1:backend-webapp runs the project’s own JavaScript build on this machine,
the one mvn package -Dcodename1.platform=javascript
-Dcodename1.buildTarget=local-javascript runs, then unpacks the bundle into the
server module’s target/webapp. It’s a goal of its own because that build takes
about a minute. Run it when the app changed, not every time the server restarts;
what it staged stays until mvn clean.
The server looks for the app in a directory named webapp in its working
directory, beside application.properties, and serves it when the directory holds
an index.html. There’s no setting to turn on. cn1:backend points the server at
target/webapp for you, and cn1:backend-package leaves the directory beside the
binary, so a deployment is the binary with webapp next to it:
/srv/myapp/
myapp-server the binary
application.properties
webapp/
index.html
...
The files aren’t linked into the binary. They’re sent from the page cache, which needs them to be files.
Routes still come first. A request reaches the app’s files only when no route
answered it, so /api/notes is the API and / is the app, and a file can’t
shadow a route by being named like one.
The files of a build keep their names from one build to the next, so each is
served with Cache-Control: no-cache and an entity tag: a browser asks every
time and is told 304 Not Modified for a file that didn’t change. A deployment
that puts a version in the path can set a longer policy. The build also writes a
gzip copy beside each text file that shrinks, and a browser that accepts gzip is
sent that copy; a range request, and a browser that doesn’t accept it, get the
file itself.
| Setting | What it does |
|---|---|
| The directory holding the app. |
| Where the app is served. |
| The |
| Set to |
The goal has a few parameters of its own:
| Parameter | What it does |
|---|---|
| A bundle to stage instead of building one: the archive a JavaScript build produced, or a directory unpacked from it. Use it for a bundle built by an earlier job or by the build server. |
| Where to stage it. |
| The app’s root project. The directory above the server module by default. |
A Gradle project has the same step as a task of its backend
project:
./gradlew :backend:backendWebApp (1)
./gradlew :backend:runBackend (2)Builds the app for the browser and stages it in
backend/build/webapp.Runs the server, which now answers
/with the app.
backendWebApp runs the root project’s local JavaScript build, the one
buildJavascriptLocal runs, and unpacks the bundle into the backend’s
build/webapp. runBackend points the server at that directory, and
backendPackage writes the binary into build, where webapp is beside it
already. What it staged stays until ./gradlew clean.
-Pcn1.backend.webapp.bundle and -Pcn1.backend.webapp.output do what the Maven
parameters of the same names do. There’s no appRoot: the app is the root
project of the build. A backend-only project has no app to build, so its
backendWebApp stages a bundle named with -Pcn1.backend.webapp.bundle.
A test that needs the app served names the directory, since a test runs in the
module directory and not in target:
@BackendTest(properties = "cn1.webapp.root=target/webapp")
class HostedAppTest {
}
Talking to the server from the hosted app
The hosted app and its API share an origin, so the app needs no server address compiled in. In the browser the address of the page is the address of the server:
String origin = CN.getProperty("browser.window.location.origin", null);
String server = origin != null ? origin : "https://api.example.com";
A same-origin request needs no CORS configuration and goes straight to the
server. The JavaScript build can route requests to other origins through a
proxy servlet; a backend isn’t a servlet container, so the hosted build is made
without one unless the project sets javascript.inject_proxy or
javascript.proxy.url itself. A hosted app that calls a third-party service
directly needs that service to allow the app’s origin.
A WebSocket follows the same rule. Replace http with ws in the origin and
the socket goes to the server the page came from, over TLS when the page did.
Signing in from the hosted app
An installed app that signs in against the server’s own authorization server
usually asks for the code to be sent to a loopback address and reads it off the
redirect. A page in a browser can’t do that. The browser follows a redirect
itself, shows the page only what was at the end of it, and refuses to show it
anything from another origin. It also keeps the session cookie to itself: the
page can neither read Set-Cookie nor set Cookie.
Three things make the same flow work there:
Register a redirect address on the server’s own origin for the app’s client, such as
https://app.example.com/signin/code, beside the loopback one. A redirect address is matched exactly, scheme, host and port included, so it has to be the address people open the app at.Answer that path with the code it was given, as JSON. The redirect then ends on a same-origin response the app can read. The path needs no sign-in: it repeats what was in its own address, and a code is worth nothing without the PKCE verifier that only the app that started the sign-in holds.
Answer the sign-in form with a status and not a redirect, using
formLogin(form → form.successHandler(…).failureHandler(…)). The app can’t see which page a redirect led to, so200and401are how it learns whether the password was accepted.
The browser then carries the session cookie from the form to /oauth2/authorize
on its own, because both are the same origin, and the token request is the same
one the installed app makes. Tokens are kept by the app’s token store, which in
a browser is the browser’s storage for that origin.
That cookie changes what the sign-in chain has to defend against. An installed app holds its session itself, so no other site can ride on it. A browser sends the cookie on its own. If the chain runs without CSRF tokens, because the app posts the form itself, two things have to do their work:
The session cookie is
HttpOnlyandSameSite=Laxunlesscn1.session.same-sitesays otherwise, andSecureunder TLS. Leave it that way:Nonesends the session along with another site’s requests.Refuse a request that changes something and names another site in
Origin. A browser sets that header on every such request and a page can’t alter it. The installed app sends none, so a request without one passes. A filter added withaddFilterBefore(filter, LogoutFilter.class)covers the form, the token endpoint and sign-out. Compare against the configured issuer.
With CSRF off, sign-out answers a GET as well as a POST. Narrow it with
logout(logout → logout.logoutRequestMatcher(…)), and have the app send
POST /logout once it has its tokens. The session has done its work by then.
One that’s never ended lasts until cn1.session.timeout seconds pass without a
request, which is 1800 unless set.
Sharing the contract with the app
This is where having the same language on both ends stops being a slogan. An interface annotated for the REST client generates the app’s client:
@RestClient
public interface NotesApi {
@GET("/notes/{id}")
void note(@Path("id") String id, OnComplete<Response<Note>> callback);
@POST("/notes")
void create(@Body Note note, OnComplete<Response<Note>> callback);
}
Building the backend module with -Dcn1.restServer=true generates two more types
from that same interface: NotesApiServer, a synchronous interface the backend
implements, and NotesApiDispatcher, which routes a method, path and body to it
and binds the path and query parameters.
public class NotesEndpoint implements NotesApiServer {
private final Map<String, Note> notes =
Collections.synchronizedMap(new HashMap<String, Note>());
public Note note(String id) { // no callback: this IS the server
return notes.get(id);
}
public Note create(Note note) {
notes.put(String.valueOf(note.id), note);
return note;
}
}
The client’s methods are asynchronous because a UI can’t block; the server’s methods are synchronous because a handler has nothing to call back into. One declaration produces both shapes, which is what gRPC does and for the same reason.
The payoff is that changing the contract breaks the build on whichever side did not follow it, instead of producing a response the app fails to parse in the field. The data transfer objects are shared rather than transcribed, and their codecs are generated on both sides, so there is no handwritten mapping layer to drift.
The server half is off by default. Every existing project carries these interfaces for its client alone, and generating server classes into those builds would grow them for nothing.
A module both sides depend on
A project with an app and a server keeps the contract in a module of its own,
which common and backend both depend on. Nothing needs switching on there:
The app’s build finds the contract in the dependency and generates its client, exactly as it does for an interface in
commonitself.The backend’s
generate-annotation-stubsgoal writes, for each contract,NotesApiServerand aNotesApiControllerintotarget/generated-sources/cn1-annotations. The controller is an ordinary@RestControllerthat takes the@ComponentimplementingNotesApiServerand maps each operation to it, so routing, binding, security rules and filters treat it like one written by hand.
The module holds the data classes and the interfaces and nothing else. It
compiles against codenameone-core for the callback types the interfaces name,
with provided scope, so the server doesn’t inherit the client library. A
contract method can’t answer with a status of its own, so the implementation
throws ResponseStatusException to refuse a request.
The ride-hailing application in scripts/wayline is built this way, and the
Initializr generates it as a starting point under your own name and package.
Real-time with WebSockets
The server speaks RFC 6455, so a Codename One app can hold a live connection to a
Codename One server with the same language on both ends. The client half is
com.codename1.io.WebSocket, which every port has shipped for years; this is the
other half of it.
An endpoint implements com.codename1.backend.WebSocket:
public static final class Echo implements WebSocket {
public void onOpen(WebSocketSession session) throws IOException {
session.sendText("welcome");
}
public void onText(WebSocketSession session, String message) throws IOException {
session.sendText(message);
}
public void onBinary(WebSocketSession session, byte[] message, int offset, int length)
throws IOException {
session.sendBinary(message, offset, length);
}
}
The build finds it, the way it finds a @RestController:
@WebSocketMapping("/chat")
public static final class ChatEndpoint implements WebSocket {
public void onOpen(WebSocketSession session) { }
public void onText(WebSocketSession session, String message) { }
public void onBinary(WebSocketSession session, byte[] message, int offset, int length) { }
}
The annotation goes on the type rather than on a method, because a @GetMapping
marks a call and a WebSocket is a connection: its contract is seven callbacks that
share per-connection state, which is an object. What it keeps from @GetMapping is
what a reader cares about — the path is relative to a class-level
@RequestMapping, a constructor taking a DataSource or an EntityManager is
injected the same way, and two endpoints claiming one path is a build error rather
than something registration order decides. Write the path’s characters out: an
escaped character such as %61 in the mapping is a build error too, since an
upgrade is matched against the decoded path, which an escaped mapping never equals.
An endpoint that injects a request- or session-scoped bean, directly or
through a singleton, builds with a warning: its callbacks run outside any HTTP
request, so there’s no request or session to find the bean in, and using it there
throws IllegalStateException, as in Spring. Keep per-connection state in the
session’s attachment instead.
Only onOpen, onText and onBinary have to be written. onPing, onPong,
onClose and onError have empty defaults, and a PING is answered with its PONG
before the endpoint is told, so an endpoint that ignores them still keeps its
connections alive.
One endpoint, many connections
An endpoint is created once for the route, not once per client, which is the same
shape a @RestController has. Everything belonging to one client lives on the
WebSocketSession the callbacks are handed, and setAttachment is where an
endpoint puts its own per-connection state.
Messages arrive whole. A client that splits a two megabyte upload into thirty-two
frames produces one onBinary, and a text message is validated as UTF-8 before it
is decoded, so a handler never sees a replacement character standing in for bytes
the client didn’t send. The array passed to onBinary is the session’s own
reassembly buffer and is valid only until that call returns — the same contract
HttpServer.Request carries, and for the same reason. An endpoint that keeps the
bytes copies the range it wants.
Sending from somewhere else
A callback runs on the thread that owns its connection, and the server reads nothing more from that connection until it returns. An endpoint may therefore block, and a slow one slows down its own client and nobody else’s.
Sending is the other way round: a session may be written to from any thread, which is what makes a broadcast possible.
public static final class Room implements WebSocket {
// Every open session in this room. A websocket endpoint is one object shared
// by every connection, so anything per-room lives here and anything per-client
// lives on the session.
private final List sessions = Collections.synchronizedList(new ArrayList());
public void onOpen(WebSocketSession session) {
sessions.add(session);
}
public void onText(WebSocketSession session, String message) {
// Sending from a thread other than the one that owns a connection is
// supported, and this is why: a broadcast reaches every session but one
// from the thread that received the message.
Object[] open = sessions.toArray();
for(int iter = 0 ; iter < open.length ; iter++) {
WebSocketSession other = (WebSocketSession)open[iter];
if(other == session) {
continue;
}
try {
other.sendText(message);
} catch (IOException err) {
// A send fails when that peer has gone. It is already being torn
// down; onClose will take it out of the list.
other.abort();
}
}
}
public void onBinary(WebSocketSession session, byte[] message, int offset, int length) {
}
public void onClose(WebSocketSession session, int code, String reason) {
sessions.remove(session);
}
}
Each session serializes its own writers, so two threads can’t interleave halves of
two messages on one connection. What the server doesn’t do is queue: sendText
writes to the socket and blocks if the peer has stopped reading. That’s honest
back pressure rather than a buffer that grows until the machine runs out, but it
means one unresponsive client can hold up a loop that broadcasts to every other
one. A server with many clients and large messages should broadcast from a small
pool rather than from the receiving thread.
Subprotocols
An endpoint that speaks more than one protocol lists them best first:
public static final class Graph implements WebSocket {
public String[] getSubprotocols() {
// The server's order decides, not the client's.
return new String[]{"graphql-transport-ws", "graphql-ws"};
}
public void onOpen(WebSocketSession session) {
if("graphql-ws".equals(session.getSubprotocol())) {
// The legacy protocol; answer in its shape.
}
}
public void onText(WebSocketSession session, String message) {
}
public void onBinary(WebSocketSession session, byte[] message, int offset, int length) {
}
}
The server’s order decides, not the client’s, so a client can’t select a
deprecated protocol over a current one by listing it first. When nothing matches,
the handshake still succeeds and names no protocol, which is what the standard
describes; getSubprotocol then answers null.
A connection, not a request
A WebSocket is a connection that stopped being HTTP, and the difference is something an author can feel.
Under virtual threads — the default on the packaged runtime — each connection owns one, and a parked virtual thread costs a stack rather than an operating system thread. Ten thousand mostly silent connections is the workload that model was built for.
On the thread pool it’s a worker per connection for as long as the connection
lasts. That’s the mode the local development loop always runs in, because the JVM
has no virtual threads here, and it’s also the mode any TLS server runs in. A
pooled deployment serving many WebSockets has to size workers for them, because
unlike a request they don’t give the worker back.
That makes workers the ceiling on concurrent connections in pool mode, and going
past it fails in a way worth knowing about: the process stays healthy, the listener
stays bound, and new connections are simply refused, because no worker ever comes
back to accept them. An eight-worker server driven by the conformance suite
reached case 9.4.4 and refused everything after it. The server says so now — it logs once when WebSockets hold half the pool — and the two ways out are more
workers or a shorter CN1_WS_IDLE_TIMEOUT_MS, so abandoned connections give their
worker back sooner. On virtual threads none of this applies.
The idle timeout is separate for the same reason. CN1_HTTP_TIMEOUT_MS sheds a
client that began a request and stopped; a WebSocket is idle by design and would
be shed within seconds by that rule. CN1_WS_IDLE_TIMEOUT_MS governs these
instead, defaults to five minutes, and accepts 0 for connections that may stay
silent indefinitely. CN1_WS_MAX_MESSAGE_MB bounds reassembly, because a message
is as large as the peer chooses to make it.
getMetrics() reports webSocketConnections, and an open session doesn’t count
towards activeRequests — it’s a connection, and counting it would make an idle
server read as permanently saturated.
stop() sends every open connection a 1001 "going away" close before the drain
window, so a shutdown reads as one to the client rather than as a network failure.
How the frame layer is checked
Most of RFC 6455 is about frames a conformant client never sends, which is how a
server ends up wrong in ways nothing it talks to will reveal. The
Autobahn|Testsuite is what finds
those, and vm/backend/ws-conformance.sh runs it:
vm/backend/ws-conformance.sh --arm javase # or --arm native
It needs Docker or Podman, starts an echo server, and drives ~300 cases through
it. The gate has no per-case tolerance: every case must pass, and NON-STRICT
counts as a failure, because a lenient frame parser is the thing this is looking
for. The set of cases that ran also has to match a committed manifest, so the
suite can’t shrink unnoticed to the ones that happen to pass.
Sections 12 and 13 are excluded while permessage-deflate is unimplemented. They’re excluded rather than tolerated: accepting their result as a pass would also accept it for a case that used to work.