Soklet Logo

Core Concepts

HTTP Server Configuration

Soklet applications do not deploy on traditional application servers like Jetty or Tomcat. There is no concept of a Servlet Container or a WAR file (although Soklet does offer Servlet integration for legacy code).

Instead, Soklet provides its own transport servers out-of-the-box. A Soklet application can be configured with any combination of:

  • a regular HTTP HttpServer for ordinary Resource Methods
  • a SseServer for SSE handshakes and streams
  • an McpServer for Model Context Protocol transport

At least one of those three must be configured.


HTTP Server

If your application serves ordinary HTTP Resource Methods, configure a HttpServer. The minimum required configuration is the port number on which to listen.

Soklet will pick sensible defaults, shown below, for other settings.

// The only required configuration is port number
HttpServer httpServer = HttpServer.withPort(8080 /* port */)
  // Host on which we are listening
  .host("0.0.0.0")
  // The number of connection-handling event loops to run concurrently.
  // You likely want the number of CPU cores as per below
  .concurrency(Runtime.getRuntime().availableProcessors())
  // How long to permit a request to process before timing out
  .requestTimeout(Duration.ofSeconds(60))
  // How long to permit your request handler logic to run
  // (Resource Method + Response Marshaling)
  .requestHandlerTimeout(Duration.ofSeconds(60))
  // Maximum number of request handler tasks that may run concurrently.
  // Defaults to concurrency when virtual threads are unavailable, or concurrency * 16 when they are.
  .requestHandlerConcurrency(Runtime.getRuntime().availableProcessors() * 16)
  // Maximum queued request handler tasks before rejecting with 503.
  // Defaults to requestHandlerConcurrency * 64.
  .requestHandlerQueueCapacity(Runtime.getRuntime().availableProcessors() * 16 * 64)
  // How long to block waiting for the socket's channel to become ready.
  // If zero, block indefinitely
  .socketSelectTimeout(Duration.ofMillis(100))
  // How long to wait for request handler threads to complete on shutdown
  .shutdownTimeout(Duration.ofSeconds(5))
  // The biggest request we permit clients to make (10 MB)
  .maximumRequestSizeInBytes(1_024 * 1_024 * 10)
  // Requests are read into a byte buffer of this size.
  // Adjust down if you expect tiny requests.
  // Adjust up if you expect larger requests.
  .requestReadBufferSizeInBytes(1_024 * 64)
  // The maximum number of pending connections on the socket
  // (values < 1 use JVM platform default)
  .socketPendingConnectionLimit(0)
  // Maximum concurrent connections (0 means unlimited)
  .maximumConnections(0)
  // Request ID generator
  .idGenerator(IdGenerator.defaultInstance())
  // Multipart parser
  .multipartParser(MultipartParser.defaultInstance())
  .build();

// Use our custom server
SokletConfig config = SokletConfig.withHttpServer(httpServer)
  // Not shown: other Soklet builder customizations
  .build();

// Start it up
try (Soklet soklet = Soklet.fromConfig(config)) {
  soklet.start();
  System.out.println("Soklet started, press [enter] to exit");
  soklet.awaitShutdown(ShutdownTrigger.ENTER_KEY);
}

ShutdownTrigger.ENTER_KEY is mainly a local-development convenience. It only works when standard input is attached to an interactive TTY, and it is process-wide across all Soklet instances in the JVM. In containers, services, and CI, prefer plain Soklet::awaitShutdown and let normal JVM shutdown hooks or OS signals stop the process.

Additional notes: requestTimeout controls how long the server waits for request data, while requestHandlerTimeout caps the total time your handler code is allowed to run. requestHandlerConcurrency and requestHandlerQueueCapacity provide backpressure by limiting how many requests can be actively processed or queued. The maximumConnections knob can be used to shed load, and idGenerator controls the values surfaced via Request::getId. Your IdGenerator receives the Request, so you can incorporate request data - for example, X-Amzn-Trace-Id.

Additional defaults not shown above: requestHandlerExecutorServiceSupplier uses a bounded virtual-thread executor when available, and multipartParser defaults to MultipartParser::defaultInstance. If you supply your own requestHandlerExecutorServiceSupplier, Soklet will use that executor and ignore requestHandlerConcurrency and requestHandlerQueueCapacity.

Virtual Threads

The default configuration will transparently use Virtual Threads if available at runtime (JDK 19 or 20 with the --enable-preview flag or JDK 21+ stock configuration) and fall back to native threads if not.

If you prefer not to use Virtual Threads, provide your own ExecutorService to requestHandlerExecutorServiceSupplier as shown above.

References:

Server-Sent Event Server

If your application supports Server-Sent Events, configure a SseServer. This is a separate server (and port) dedicated to SSE connections. A regular HTTP HttpServer is only needed if the same app also exposes ordinary HTTP Resource Methods. See the Server-Sent Events documentation for details.

The minimum required configuration is the port number on which to listen.

Soklet will pick sensible defaults, shown below, for other settings.

// The only required configuration is port number
SseServer sseServer = SseServer.withPort(8081 /* port */)
  // Host on which we are listening
  .host("0.0.0.0")
  // How long to permit an SSE handshake request to process before timing out
  .requestTimeout(Duration.ofSeconds(60))
  // How long to permit your SSE handshake handler logic to run
  .requestHandlerTimeout(Duration.ofSeconds(60))
  // Maximum number of SSE handshake tasks that may run concurrently.
  // Defaults to availableProcessors * 16.
  .requestHandlerConcurrency(Runtime.getRuntime().availableProcessors() * 16)
  // Maximum queued SSE handshake tasks before rejecting with 503.
  // Defaults to requestHandlerConcurrency * 64.
  .requestHandlerQueueCapacity(Runtime.getRuntime().availableProcessors() * 16 * 64)
  // How long to wait when writing SSE data before timing out
  // (0 disables write timeouts)
  .writeTimeout(Duration.ZERO)
  // How often to send heartbeat payloads to keep connections alive
  .heartbeatInterval(Duration.ofSeconds(15))
  // How long to wait for SSE threads to complete on shutdown
  .shutdownTimeout(Duration.ofSeconds(1))
  // The biggest SSE handshake request we permit clients to make (64 KB)
  .maximumRequestSizeInBytes(1_024 * 64)
  // Requests are read into a byte buffer of this size
  .requestReadBufferSizeInBytes(1_024)
  // Maximum concurrent SSE connections (global cap).
  // If exceeded, a 503 is returned via ResponseMarshaler::forServiceUnavailable
  .concurrentConnectionLimit(8_192)
  // Cache sizes for broadcasters (per-resource-path event fanout)
  // and resource path declarations (Route pattern lookups).
  // Increase if you have many distinct SSE URLs in circulation.
  .broadcasterCacheCapacity(1_024)
  .resourcePathCacheCapacity(8_192)
  // Maximum queued SSE writes per connection
  .connectionQueueCapacity(128)
  // Write an initial heartbeat to verify the connection after handshake
  .verifyConnectionOnceEstablished(true)
  // Request ID generator
  .idGenerator(IdGenerator.defaultInstance())
  .build();

SokletConfig config = SokletConfig.withSseServer(sseServer)
  .build();

If you also serve ordinary HTTP Resource Methods, add .httpServer(HttpServer.fromPort(8080)).

Additional notes: requestTimeout and requestHandlerTimeout have the same meaning as the regular server, but apply only to the SSE handshake. requestHandlerTimeout starts when the connection is accepted and includes time spent waiting in the handshake queue. writeTimeout governs how long to allow streaming writes before failing the connection; Duration.ZERO disables write timeouts. requestHandlerConcurrency and requestHandlerQueueCapacity provide backpressure for SSE handshakes (they do not limit active SSE connections).

SSE connections and broadcasters are node-local. In clustered deployments, a local broadcast only reaches clients connected to that node, so production systems usually publish events to a shared queue or pub/sub system and have each Soklet node perform its own local rebroadcast. If you support Last-Event-ID catch-up, the replay data should come from a shared durable store or log.

Additional defaults not shown above: requestHandlerExecutorServiceSupplier uses a bounded virtual-thread executor for handshakes, the request reader uses a separate bounded virtual-thread executor internally, and established SSE connections are processed on a virtual-thread-per-connection executor. If you supply your own requestHandlerExecutorServiceSupplier, Soklet will use that executor and ignore requestHandlerConcurrency and requestHandlerQueueCapacity for the handshake executor.

References:

MCP Server

If your application supports Model Context Protocol, configure an McpServer. Like SSE, this is a separate server and must listen on its own port. A regular HTTP HttpServer is only needed if the same app also exposes ordinary HTTP Resource Methods.

McpServer mcpServer = McpServer.withPort(8082)
  .handlerResolver(McpHandlerResolver.fromClasspathIntrospection())
  .requestTimeout(Duration.ofSeconds(60))
  .requestHandlerTimeout(Duration.ofSeconds(60))
  .requestHandlerConcurrency(Runtime.getRuntime().availableProcessors() * 16)
  .requestHandlerQueueCapacity(Runtime.getRuntime().availableProcessors() * 16 * 64)
  .maximumRequestSizeInBytes(1_024 * 1_024 * 10)
  .requestReadBufferSizeInBytes(1_024 * 64)
  .writeTimeout(Duration.ZERO)
  .heartbeatInterval(Duration.ofSeconds(15))
  .connectionQueueCapacity(128)
  .idGenerator(IdGenerator.defaultInstance())
  .build();

SokletConfig config = SokletConfig.withMcpServer(mcpServer)
  .build();

If you also serve ordinary HTTP Resource Methods, add .httpServer(HttpServer.fromPort(8080)).

Additional notes: requestTimeout, requestHandlerTimeout, requestHandlerConcurrency, and requestHandlerQueueCapacity work similarly to the regular HTTP server. writeTimeout, heartbeatInterval, concurrentConnectionLimit, and connectionQueueCapacity control the session-bound MCP GET event streams for the configured endpoint path. Unlike ordinary HTTP resources, MCP also requires an McpHandlerResolver and uses an McpCorsAuthorizer for transport-specific browser CORS behavior.

The default McpSessionStore is in-memory. In clustered deployments, you will usually want a shared custom store, commonly backed by Redis or SQL, plus session-affine routing keyed by MCP-Session-Id so the node that owns the live MCP GET stream continues to receive that session's requests.

For transport rules, session lifecycle, and supported protocol features, see the MCP documentation.

References:

Previous
Value Conversions