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:

AnnotationReadsBinds to

@PathVariable("id")

a {id} segment of the path

String, or a numeric or boolean primitive

@RequestParam("q")

a query parameter, or a field of a form body

String, or a numeric or boolean primitive

@RequestPart("file")

one part of a multipart/form-data body

HttpServer.Part, byte[], or String for a text field

@RequestHeader("Idempotency-Key")

a request header

String, or a numeric or boolean primitive

@RequestBody

the body

String, a Map or a List, or a class of your own and collections of it

none, typed HttpServer.Request

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 typeResponse

void

204 with no body, or the @ResponseStatus code.

String

The text, as text/plain; charset=utf-8, with 200 or the @ResponseStatus code.

A Map, List, Set, primitive or box, or a class of your own and collections of it

JSON, with 200 or the @ResponseStatus code.

HttpServer.Response

Exactly what the method built. @ResponseStatus on such a method is a build error, since the response already carries its own status.

null, from any of the above

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 transient ones. A public field is used directly; any other through its getX (or isX) and setX methods, and is left out when it has neither.

  • @JsonProperty("due_at") renames a field and @JsonIgnore leaves one out, both from com.codename1.annotations.

  • A Date is 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 as 2025-03-01T09:30:00Z. A byte[] 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 raw Map or List, 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 its toString(), 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
KeyWhat it sets

cn1.cors.allowedOrigins

The origins that may call the server, separated by commas, or * for any. Each is scheme://host[:port] with no path, as a browser sends it in Origin; the server refuses to start with anything else. Cross-origin requests are refused while this is unset.

cn1.cors.allowedMethods

The methods a cross-origin request may use, preflighted or not. All the common ones by default.

cn1.cors.allowedHeaders

The request headers a preflight allows, * by default.

cn1.cors.exposedHeaders

Response headers beyond the basic ones that the page may read.

cn1.cors.allowCredentials

Whether the browser sends cookies and the page may read the answer. A browser refuses this with a * origin, so the server refuses that combination when it starts.

cn1.cors.maxAgeSeconds

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)
  1. Builds the app for the browser and stages it in backend/target/webapp.

  2. 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.

SettingWhat it does

cn1.webapp.root

The directory holding the app. webapp in the working directory by default. A directory named here that holds no index.html stops the server from starting, since a server told where its app is shouldn’t answer 404 there.

cn1.webapp.path

Where the app is served. / by default.

cn1.webapp.cacheControl

The Cache-Control header of every file. no-cache by default.

cn1.webapp.enabled

Set to false to serve no app even when the directory is there.

The goal has a few parameters of its own:

ParameterWhat it does

cn1.backend.webapp.bundle

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.

cn1.backend.webapp.output

Where to stage it. target/webapp in the server module by default.

cn1.backend.webapp.appRoot

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)
  1. Builds the app for the browser and stages it in backend/build/webapp.

  2. 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, so 200 and 401 are 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 HttpOnly and SameSite=Lax unless cn1.session.same-site says otherwise, and Secure under TLS. Leave it that way: None sends 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 with addFilterBefore(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 common itself.

  • The backend’s generate-annotation-stubs goal writes, for each contract, NotesApiServer and a NotesApiController into target/generated-sources/cn1-annotations. The controller is an ordinary @RestController that takes the @Component implementing NotesApiServer and 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.