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 needs nothing server-specific 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. Servlet 6.1 is one version 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, status pages, JSON, templates, static files, SSE, multipart uploads, sessions, and WebTest. WebTest starts the app with app.start(0), so the test suite runs on Tomcat once the app names it, and its tests do not change.

What is Tomcat’s own

Graceful shutdown is hand-rolled here, because Tomcat has no stopTimeout of its own. A valve on the engine counts the requests in flight, and once stop() begins it answers every new request 503 with Connection: close. stop() then waits within the timeout for the count to reach zero, which is how Undertow’s GracefulShutdownHandler drains. The drain counts requests rather than threads, so it works on any executor, the virtual-thread one included. An executor passed to executor(…​) belongs to the application, and stop() leaves it running for a restart or for the other work on it. The connector is not paused for the drain. A paused connector closes a keep-alive connection that sends a new request without answering it, and a client retries such a request only when it is idempotent.

The shutdown hook is hand-rolled too. Jetty’s setStopAtShutdown deregisters itself. This module’s hook is registered on start and removed again on stop, so a suite that starts a server per test accumulates none.

Tomcat requires a catalina.base directory on disk. The default is a temporary directory created on start and deleted on stop, so nothing lands next to the build. baseDir(…​) replaces that default with a directory of your own, which is left alone on stop.

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

Deploying to a Tomcat you did not start

Nothing on this page applies to a WAR dropped into a standalone Tomcat. A WAR deployment needs no module at all. Map AppServlet yourself and exclude the embedded server, as Deployment describes.