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.


Runtime Concurrency Model

Soklet keeps socket/event-loop work separate from application request handling. Standard HTTP event loops accept connections, parse request bytes, dispatch request handling, and write finalized response bytes. The request-handler executor runs Resource Method execution, RequestInterceptor::interceptRequest, RequestBodyMarshaler, and ResponseMarshaler work. StreamingResponseBody producers run on the streaming executor so long-lived producers do not occupy request-handler threads; event loops write the queued stream bytes to sockets.

requestHandlerConcurrency and requestHandlerQueueCapacity apply to request-handler work, not to accept loops, connection event loops, or already-established streaming writes. If you supply requestHandlerExecutorServiceSupplier, Soklet uses your executor directly and does not wrap it with the standard concurrency and queue-capacity controls.

SSE uses request-handler executor capacity for handshake Resource Methods. Established SSE streams run on a virtual-thread-per-connection executor and are controlled by connection caps, queue capacity, heartbeat, write timeout, and shutdown settings rather than by handshake concurrency.

MCP JSON-RPC request handling uses the MCP request-handler executor. Established MCP GET streams run on a virtual-thread-per-stream executor on JDK 21+ and other virtual-thread-capable runtimes; without virtual threads, Soklet uses a bounded fallback stream executor as described in the MCP section below.


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 the request line and headers to arrive
  .requestHeaderTimeout(Duration.ofSeconds(60))
  // How long to permit the request body to arrive after headers
  .requestBodyTimeout(Duration.ofSeconds(60))
  // Policy for transparently decompressing eligible gzip request bodies.
  // Disabled by default.
  .requestDecompressionPolicy(RequestDecompressionPolicy.disabledInstance())
  // Maximum idle duration while writing a non-streaming response.
  // Defaults to 60 seconds; Duration.ZERO disables this timeout.
  .responseWriteIdleTimeout(Duration.ofSeconds(60))
  // Policy for gzip-compressing eligible finalized in-memory responses.
  // Disabled by default.
  .responseGzipPolicy(ResponseGzipPolicy.disabledInstance())
  // 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)
  // Per-stream producer queue capacity for StreamingResponseBody.
  // Defaults to 1 MB per stream.
  .streamingQueueCapacityInBytes(1_024 * 1_024)
  // Maximum streaming payload chunk size.
  // Defaults to 16 KB.
  .streamingChunkSizeInBytes(1_024 * 16)
  // Maximum total duration for a streaming response.
  // Duration.ZERO disables this timeout.
  .streamingResponseTimeout(Duration.ZERO)
  // Maximum idle duration between bytes produced for a streaming response.
  // Defaults to requestBodyTimeout; Duration.ZERO disables this timeout.
  .streamingResponseIdleTimeout(Duration.ofSeconds(60))
  // 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 HTTP request we permit clients to make (10 MB)
  // This includes request line, headers, transfer framing, and body bytes.
  .maximumRequestSizeInBytes(1_024 * 1_024 * 10)
  // Maximum number of header fields accepted in one request.
  .maximumHeaderCount(100)
  // Maximum header-section size accepted in one request (64 KB).
  .maximumHeadersSizeInBytes(64 * 1_024)
  // Maximum request-target length accepted in bytes.
  .maximumRequestTargetLengthInBytes(8_192)
  // 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 disables the cap)
  .concurrentConnectionLimit(8_192)
  // 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 reads from standard input, so IDE consoles such as IntelliJ work even when the JVM has no System::console. If stdin is unavailable or reaches EOF before a keypress, Soklet logs that the trigger is unsupported and keeps running. The trigger 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: requestHeaderTimeout controls how long the server waits for the request line and headers, while requestBodyTimeout controls total body-read time after headers have been received. requestBodyTimeout is not an idle-progress timeout: a slow upload can exceed it even if bytes keep arriving. responseWriteIdleTimeout controls how long a non-streaming response may go without socket-write progress; it protects fixed-length and file responses from stalled readers, resets when Soklet successfully writes response bytes, defaults to 60 seconds, and can be disabled with Duration.ZERO. responseGzipPolicy enables opt-in gzip compression for eligible finalized in-memory byte-array and ByteBuffer responses when Accept-Encoding permits gzip; Soklet adds or extends Vary: Accept-Encoding, removes stale Content-Length, and skips streaming, file, file-channel, already-encoded, ranged, bodyless, and transfer-encoded responses before invoking the policy. Use ResponseGzipPolicy::fromDefaultsWithMinimumBodySizeInBytes for common text-like response media types, or provide a lambda for application-specific decisions. 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. During shutdown, the standard HTTP server stops accepting new connections, closes idle keep-alives, lets already-dispatched handlers flush responses with Connection: close, and then force-closes remaining connections at shutdownTimeout. streamingQueueCapacityInBytes, streamingChunkSizeInBytes, streamingResponseTimeout, and streamingResponseIdleTimeout apply to StreamingResponseBody responses only. The streaming idle timeout is producer-side: it resets when the stream producer enqueues bytes, not when a slow client reads from the socket. Streaming producers run on a separate executor so long-lived streams do not occupy request handler threads; provide streamingExecutorServiceSupplier if you need to own that executor. maximumRequestSizeInBytes applies to the whole received HTTP request, including request line, headers, transfer framing, and body bytes. maximumHeaderCount, maximumHeadersSizeInBytes, and maximumRequestTargetLengthInBytes bound request shape independently from total byte size; maximumHeadersSizeInBytes counts the header section after the request line, including header-field line endings and the terminating blank line. Leave headroom if you are thinking in terms of payload size: a body exactly equal to the configured limit may be rejected once request metadata is counted. If a client exceeds the content-size limit after Soklet has enough data to identify the request, the usual 413 Content Too Large marshaling path runs. Request-target length violations receive 414 URI Too Long; header count and header-section size violations receive 431 Request Header Fields Too Large. If a limit is exceeded before a request target can be parsed, Soklet may close the connection instead. The concurrentConnectionLimit knob can be used to shed load; its default is 8_192, and 0 disables Soklet's standard HTTP connection cap. Tune the production value based on your file-descriptor limit and edge connection policy. 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.

requestDecompressionPolicy enables opt-in transparent request-body decompression for the standard HTTP server. Enabled policies support a single Content-Encoding: gzip or x-gzip coding using JDK gzip, remove Content-Encoding and Transfer-Encoding, and replace Content-Length with the decompressed byte count before handlers see the Request. Handlers receive those decompressed bytes through Request::getBody; Request::getEncodedBodySizeInBytes retains the pre-decompression payload size for wire-oriented telemetry. Unsupported codings and coding chains return 415 Unsupported Media Type; malformed gzip bodies return 400 Bad Request; decompressed-size or compression-ratio violations return 413 Content Too Large through the usual ResponseMarshaler::forContentTooLarge path. The decompressed-size cap defaults to maximumRequestSizeInBytes, and the wire request is still bounded by maximumRequestSizeInBytes before decompression. SSE, MCP, and simulator requests are not decompressed by this setting.

Additional defaults not shown above: requestHandlerExecutorServiceSupplier and streamingExecutorServiceSupplier use bounded virtual-thread executors 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

Soklet supports JDK 17+ for standard HTTP and MCP servers. Server-Sent Events require virtual threads, which means JDK 21+ for normal production use.

The default HTTP and MCP 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 HttpServer.Builder::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 the SSE handshake request line and headers to arrive
  .requestHeaderTimeout(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.
  // Defaults to 30 seconds; Duration.ZERO disables write timeouts.
  .writeTimeout(Duration.ofSeconds(30))
  // 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)
  // This includes request line and headers.
  .maximumRequestSizeInBytes(1_024 * 64)
  // Maximum number of header fields accepted in one handshake.
  .maximumHeaderCount(100)
  // Maximum header-section size accepted in one handshake (64 KB).
  .maximumHeadersSizeInBytes(64 * 1_024)
  // Maximum handshake request-target length accepted in bytes.
  .maximumRequestTargetLengthInBytes(8_192)
  // Requests are read into a byte buffer of this size
  .requestReadBufferSizeInBytes(1_024)
  // Maximum concurrent SSE connections (global cap; 0 disables the 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: requestHeaderTimeout and requestHandlerTimeout have the same meaning as the regular server, but apply only to the SSE handshake. SSE handshakes do not accept request bodies, so there is no SSE request-body timeout. maximumRequestSizeInBytes also applies only to the handshake request, not to the established event stream. maximumHeaderCount, maximumHeadersSizeInBytes, and maximumRequestTargetLengthInBytes bound handshake request shape independently from total byte size. Request-target length violations receive 414 URI Too Long; header count and header-section size violations receive 431 Request Header Fields Too Large. requestHandlerTimeout starts when the connection is accepted and includes time spent waiting in the handshake queue. writeTimeout defaults to 30 seconds and 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). During shutdown, active SSE streams are terminated with StreamTerminationReason.SERVER_STOPPING, and queued executor work is allowed to drain until shutdownTimeout before Soklet interrupts stragglers.

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())
  .requestHeaderTimeout(Duration.ofSeconds(60))
  .requestBodyTimeout(Duration.ofSeconds(60))
  .requestHandlerTimeout(Duration.ofSeconds(60))
  .requestHandlerConcurrency(Runtime.getRuntime().availableProcessors() * 16)
  .requestHandlerQueueCapacity(Runtime.getRuntime().availableProcessors() * 16 * 64)
  // Maximum accepted MCP request size.
  // Requests with a larger declared body size are rejected.
  .maximumRequestSizeInBytes(1_024 * 1_024 * 10)
  .maximumHeaderCount(100)
  .maximumHeadersSizeInBytes(64 * 1_024)
  .maximumRequestTargetLengthInBytes(8_192)
  .requestReadBufferSizeInBytes(1_024 * 64)
  // How long to wait when writing MCP stream data before timing out.
  // Defaults to 30 seconds; Duration.ZERO disables write timeouts.
  .writeTimeout(Duration.ofSeconds(30))
  .heartbeatInterval(Duration.ofSeconds(15))
  // Maximum concurrent MCP GET stream connections (0 disables the cap)
  .concurrentConnectionLimit(8_192)
  .connectionQueueCapacity(128)
  // Reject browser-originated MCP transport requests unless explicitly allowed
  .corsAuthorizer(McpCorsAuthorizer.nonBrowserClientsOnlyInstance())
  // In-memory session store with 24-hour idle expiry, default ID generation,
  // and a maximum of 8,192 active sessions (0 disables the session cap)
  .sessionStore(McpSessionStore.builder()
    .idleTimeout(Duration.ofHours(24))
    .concurrentSessionLimit(8_192)
    .build())
  .build();

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

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

Additional notes: requestHeaderTimeout, requestBodyTimeout, requestHandlerTimeout, requestHandlerConcurrency, and requestHandlerQueueCapacity work similarly to the regular HTTP server for MCP JSON-RPC request handling. requestBodyTimeout bounds total body-read time, not idle time between body bytes. requestHandlerTimeout covers MCP JSON-RPC handler execution, including framework-managed McpEndpoint::initialize. maximumRequestSizeInBytes is checked before the MCP request is dispatched for declared body size, while maximumHeadersSizeInBytes rejects oversized header sections independently. maximumHeaderCount and maximumRequestTargetLengthInBytes also bound request shape independently from total byte size. Oversized MCP request bodies receive 413 Content Too Large when the request is otherwise parseable; request-target length violations receive 414 URI Too Long; header count and header-section size violations receive 431 Request Header Fields Too Large; malformed request-shape violations receive 400 Bad Request. writeTimeout, heartbeatInterval, concurrentConnectionLimit, and connectionQueueCapacity control the session-bound MCP GET event streams for the configured endpoint path. On JDK 21+ and other runtimes with virtual threads enabled, established MCP GET streams are processed on a virtual-thread-per-stream executor and are not limited by requestHandlerConcurrency. On runtimes without virtual threads, live-stream processing uses a bounded fallback executor: requestHandlerConcurrency bounds active stream workers, and connectionQueueCapacity also bounds queued stream tasks. writeTimeout defaults to 30 seconds; Duration.ZERO disables write timeouts. concurrentConnectionLimit defaults to 8_192, and 0 disables Soklet's MCP stream connection cap entirely; use 0 in production only when a proxy, load balancer, operating-system limit, or custom admission policy provides the intended cap. During shutdown, active MCP SSE streams are terminated with StreamTerminationReason.SERVER_STOPPING, and queued executor work is allowed to drain until shutdownTimeout before Soklet interrupts stragglers. Unlike ordinary HTTP resources, MCP also requires an McpHandlerResolver and uses an McpCorsAuthorizer for transport-specific browser CORS behavior.

McpSessionStore::builder configures Soklet's default in-memory MCP session store. Its default MCP-Session-Id generation is cryptographically strong and visible-ASCII-safe. Custom MCP session ID generators should preserve those properties; node or region prefixes are fine if the remaining token is still unguessable.

The default store is in-memory with a 24-hour idle timeout, opportunistic expiry during lookups and subsequent session creation, and a default 8_192 active-session cap. Reaching the cap rejects new initialize requests with 503 Service Unavailable; 0 disables the in-memory store's cap. In clustered deployments, provide a shared custom store, commonly backed by Redis or SQL. Custom stores are responsible for generating session IDs, applying their own concurrency caps atomically with session creation, and preserving compare-and-set replacement semantics. You still need 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.

For deployment boundaries, threading details, shutdown behavior, and multi-node guidance, see Production Readiness.

References:

Previous
Value Conversions