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 a tie to one server belongs in the artifact’s name rather than inside core. 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('net.benelog.spidersilk:spider-silk-tomcat:1.1.0') {
        exclude group: 'org.eclipse.jetty.ee10'    // core's Jetty, unused here
    }
}

The module depends on tomcat-embed-core 11.0.x, the Servlet 6.1 line. That is one level above core’s servlet API, Jetty’s ee10, and Undertow 2.3.x, which are all Servlet 6.0. Core calls nothing that Servlet 6.1 added, so the same application runs on all three servers.

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, which is the default but not necessarily what executor(…​) hands it. The virtual-thread executor therefore 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.