Tomcat

spider-silk-tomcat runs the same app on an embedded Tomcat instead of the Jetty bundled with core. It is a separate module because being tied to one server is a thing worth saying in the artifact’s name rather than hiding inside core, and because core keeps its promise either way: AppServlet is a plain servlet, and WebServer is four methods.

For how the three servers compare and which to pick, see Choosing a server.

Installation

dependencies {
    implementation('io.github.benelog.spidersilk:spider-silk-tomcat:0.1.0-SNAPSHOT') {
        exclude group: 'org.eclipse.jetty.ee10'    // core's Jetty, unused here
    }
}

The module depends on tomcat-embed-core 10.1.x, the Servlet 6.0 line — the same level as core’s servlet API, as Jetty’s ee10, and as Undertow 2.3.x. Tomcat 11 implements Servlet 6.1 and would be a version skew for no gain.

Excluding Jetty is optional; it only keeps an unused server off the classpath.

Running

new App()
        .get("/hello/{name}", req -> WebResponse.text("Hello " + req.pathParam("name")))
        .server((app, port) -> new TomcatServer(app).port(port))
        .start(8080);

The TomcatServer surface mirrors JettyServer method for method, so switching is one line at the factory:

new TomcatServer(app)
        .port(8443)
        .host("127.0.0.1")
        .contextPath("/app")
        .baseDir(Path.of("/var/tmp/tomcat"))    // default: a temp dir, deleted on stop
        .executor(Executors.newVirtualThreadPerTaskExecutor())
        .multipart(new MultipartConfigElement(tmp, 10_485_760L, 10_485_760L, 1_048_576))
        .stopTimeout(Duration.ofSeconds(20))
        .shutdownHook(false)
        .customizeConnector(connector -> connector.setProperty("maxThreads", "400"))
        .customizeContext(context -> context.addParameter("mode", "production"))
        .customizeTomcat(tomcat -> tomcat.getHost().setAutoDeploy(false))
        .start();

Everything core provides works unchanged: routing, filters, error handlers, JSON, templates, static files, SSE, multipart uploads, sessions, and WebTest. WebTest never starts a server at all, so the test suite does not know or care which server is on the classpath.

What is Tomcat’s own

Graceful shutdown is hand-rolled here, because Tomcat has no stopTimeout of its own: stop() pauses the connector and then shuts the request pool down within the timeout, which is what Spring Boot does for the same reason. That has one consequence worth stating plainly. stopTimeout(…​) only has something to wait on while the connector runs a ThreadPoolExecutor — the default, but not what executor(…​) necessarily hands it, so the virtual-thread executor leaves the drain a no-op. Undertow has no such catch, since its drain counts requests rather than threads.

The shutdown hook is ours too. Jetty’s setStopAtShutdown deregisters itself; this one is registered on start and removed again on stop, so a suite that starts a server per test accumulates none.

Tomcat wants a catalina.base on disk. The default is a temporary directory created on start and deleted on stop, so nothing lands next to the build; baseDir(…​) takes it over.

There is no sessions(boolean): Tomcat’s StandardContext installs a session manager on start and offers no way out, and a method that silently did nothing would be worse than its absence. Logging goes through JULI, so routing it into an application’s own logging means jul-to-slf4j and a bridge handler.

Deploying to a Tomcat you did not start

None of this applies to a WAR dropped into a standalone Tomcat. That path needs no module at all — map AppServlet yourself and exclude the embedded server, as Deployment describes.