Server Integration & Deployment

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against GWT 2.13.1 (2026-06-19), gwt-maven-plugin 1.3.0, JDK 21 running the compiler with -sourceLevel 17, the Jakarta Servlet 6 stack of the modular-webapp archetype (the Jetty 12.1.10 ee10 Maven plugin) and, for the second packaging, Spring Boot 4.1.1 with its embedded Tomcat 11.0.24. The GWT facts were checked on 2026-10-08 and the Spring Boot, Tomcat and Docker Hub facts on 2026-10-09. It is server and build knowledge: nothing here is compiled to JavaScript except the three small client classes of the file-upload and host-page examples. Every command, header and log line shown comes from a private copy of the Bookshelf scenario of this section, extended with the classes printed below, built with JDK 21 and Maven 3.9.16 and exercised with curl and with headless Chrome 155 driven over the DevTools protocol. Policy-file names are 32-character content hashes in the real output; in the logs and listings below they are shown as CATALOG_POLICY_HASH and TOKEN_POLICY_HASH, and the session token and upload identifiers as placeholders. Three things were not run and are marked where they appear: a Tomcat installation (Spring Boot’s embedded Tomcat stands in for it), the Dockerfile (the Docker daemon was not running) and any CDN.

Deploying a GWT application is easy in principle, because the compiler writes ordinary static files, and it goes wrong in a handful of recurring ways, because the GWT-RPC servlet reads some of those static files at run time. The page therefore starts with what exactly is deployed, then shows the same Bookshelf as a WAR and as a Spring Boot jar, and then covers the parts that surround the module directory: the host page, HTTP caching and compression, containers and file upload.

What is deployed

A GWT application consists of four groups of files, and they have different owners and different rules.

Table 1. The four groups of files of a deployed application
Group Written by Served to browsers Rule

The host page (index.html or a servlet’s output)

You. Any page that has a script tag for the selection script.

Yes

Cache little or not at all. It may carry per-user data.

The module directory (bookshelf/): bookshelf.nocache.js, one <hash>.cache.js per permutation, deferredjs/ fragments, <hash>.gwt.rpc policy files, the public path (theme CSS and images)

The GWT compiler, into the -war directory (the gwt-maven-plugin gwt-app packaging puts it in the WAR of the client module).

Yes, except that the server also reads the .gwt.rpc files itself

Hashed files forever, fixed names revalidated.

The server code: servlets, filters, DTO classes

You, with javac. Only the shared classes are also compiled to JavaScript.

Never

Lives in WEB-INF/classes and WEB-INF/lib of a WAR, or in the jar of a Spring Boot application.

The RPC library: gwt-servlet-jakarta (or gwt-servlet before Jakarta)

Maven, from the org.gwtproject BOM.

Never

One jar of about 9.6 MiB inside WEB-INF/lib. Do not also put gwt-user there.

What the compiler does not put in the servable directory is as important. The -deploy directory receives "deployable but not servable" files: the compiler’s own default is WEB-INF/deploy under the -war directory, and gwt-maven-plugin redirects it to ${project.build.directory}/gwt/deploy, outside the WAR (its parameter is deploy). For the Bookshelf that directory is 6.4 MiB: the symbolMaps and the rpcPolicyManifest. They are what turns obfuscated stack traces back into Java names (see the Logging page), so keep them with the release, but do not serve them. The -extra directory holds files that are not intended for deployment at all, such as reports. The compiler options and the three output directories are described in Compiler & Compilation, the module XML and the host page in Project Structure & Modules and the Maven packagings in Build Tools: Maven & Gradle.

The Bookshelf deployed as a WAR and as a Spring Boot jar, with the caching rule of every file group, and the four requests of a page load

The Bookshelf, built and listed

The scratch Bookshelf has the modules bookshelf-shared, bookshelf-client (packaging gwt-app), bookshelf-server (a war that depends on the client’s WAR, so that Maven’s WAR plugin overlays the module directory onto the server’s static files) and, for this page, bookshelf-assets and bookshelf-boot, which are explained in the Spring Boot section. One reactor build produces everything:

Building every module
mvn -B package -DskipTests
[INFO] Reactor Summary for bookshelf 1.0-SNAPSHOT:
[INFO] bookshelf .......................................... SUCCESS
[INFO] bookshelf-shared ................................... SUCCESS
[INFO] bookshelf-client ................................... SUCCESS
[INFO] bookshelf-server ................................... SUCCESS
[INFO] bookshelf-assets ................................... SUCCESS
[INFO] bookshelf-boot ..................................... SUCCESS
[INFO] BUILD SUCCESS

The WAR of the server module (after mvn clean package) contains 50 files: 11,888,376 bytes uncompressed and 9,443,734 bytes as a WAR. With the 32-character hashes replaced by <hash> and equal names counted, it holds:

Contents of bookshelf-server-1.0-SNAPSHOT.war
   6 bookshelf/<hash>.cache.js                       six permutations: locale default, en, es x user.agent gecko1_8, safari
  12 bookshelf/deferredjs/<hash>/1.cache.js, 2.cache.js   two code-split fragments per permutation
   2 bookshelf/<hash>.gwt.rpc                        one RPC policy per RemoteService interface
   1 bookshelf/bookshelf.nocache.js                  the selection script (7,847 bytes)
   1 bookshelf/compilation-mappings.txt              which permutation serves which property values
   1 bookshelf/bookshelf.devmode.js                  helper used by development mode
   1 bookshelf/clear.cache.gif
   8 bookshelf/gwt/clean/...                         the public path of the Clean theme: two CSS files, six images
   2 inline.html, favicon.ico                        static files of the server module (the host page is a servlet)
   1 WEB-INF/web.xml
   8 WEB-INF/classes/com/example/bookshelf/server/*.class
   2 WEB-INF/lib/gwt-servlet-jakarta-2.13.1.jar, bookshelf-shared-1.0-SNAPSHOT.jar
   5 META-INF/: MANIFEST.MF and the pom files of the two modules

Run mvn clean package, not only mvn package: the overlay of the client’s WAR is copied into the server module’s target directory without removing files of earlier builds, and a build made after the client had been recompiled had twelve <hash>.cache.js files for six permutations, the other six being the previous build’s, which a deployment would then carry. The -deploy directory (not in the WAR) contains the rpcPolicyManifest/manifest.txt that maps each service to its policy file:

# Module bookshelf
# RPC service class, partial path of RPC policy file
com.example.bookshelf.shared.CatalogService, CATALOG_POLICY_HASH.gwt.rpc
com.google.gwt.user.client.rpc.XsrfTokenService, TOKEN_POLICY_HASH.gwt.rpc

The two policy files list the types the server may serialise for each service, with one line per type (the type name, four flags, the type signature and a number); the Bookshelf’s lists Book, BookPage and BookNotFoundException for the catalog and XsrfToken and RpcTokenException for the token service. The GWT-RPC page explains the file format and the serialisation rules. For deployment only one fact matters: the server opens these files by name, and a server that cannot open them serialises less than you expect (see the pitfalls table).

Which RPC jar

The server-side RPC classes exist twice. The javax ones are in gwt-servlet, the Jakarta ones are in gwt-servlet-jakarta, in the package com.google.gwt.user.server.rpc.jakarta (verified in the 2.13.1 jar: RemoteServiceServlet, XsrfProtectedServiceServlet, XsrfTokenServiceServlet, RPC, RPCServletUtils). The client-side interfaces (RemoteService, AsyncCallback, XsrfProtectedService) are in the package com.google.gwt.user.client.rpc in both jars, so the shared code of an application does not change when the container does. The choice follows the servlet API of the container:

Table 2. Which RPC artifact for which container
Container Servlet API RPC artifact

Tomcat 9.0

4.0 (javax.servlet)

org.gwtproject:gwt-servlet

Tomcat 10.1

6.0 (jakarta.servlet)

org.gwtproject:gwt-servlet-jakarta

Tomcat 11.0 (used by Spring Boot 4.1.1 here)

6.1

org.gwtproject:gwt-servlet-jakarta

Jetty 12 with the ee10 environment (the Maven plugin of the archetype)

Jakarta EE 10

org.gwtproject:gwt-servlet-jakarta

Tomcat 10.0 implements the Servlet 5.0 API and is not covered by this page. Both artifacts are managed by the org.gwtproject:gwt BOM at 2.13.1, so the server module declares them without a version:

<dependency>
  <groupId>org.gwtproject</groupId>
  <artifactId>gwt-servlet-jakarta</artifactId>
</dependency>
<dependency>
  <groupId>jakarta.servlet</groupId>
  <artifactId>jakarta.servlet-api</artifactId>
  <scope>provided</scope>
</dependency>

The official guide still says to copy gwt-servlet.jar into war/WEB-INF/lib, which is the Ant-era instruction for the same thing; with Maven the dependency does it. The 2.11.0 release notes introduced the Jakarta artifacts.

Why the server needs the module directory

When a browser sends an RPC request, the body names the module base URL and the strong name of the policy (see the captured request body in the section about prefixes). RemoteServiceServlet removes the context path from the path of the module base URL, appends the strong name and .gwt.rpc, and calls ServletContext.getResourceAsStream with the result. The 2.13.1 source states it in three branches, each with its own log message: the module path must start with the context path of the web application, the file must exist, and it must parse. If anything fails the servlet logs the error and falls back to a legacy 1.3.3-compatible policy, which allows far fewer types. Two consequences follow, and the rest of the page is largely about them:

  • the policy files must be reachable through the servlet context of the application that runs the servlet, at the path of the module base URL, even when a CDN or a static web server serves the same files to browsers;

  • the module base URL is whatever URL the selection script was loaded from, so the host page, the static files and the RPC servlets share a path prefix, normally /<module>/.

The servlet side: web.xml

Servlets for GWT-RPC are declared like any other servlet. The Developer Guide’s rule is that the <url-pattern> is the module’s output directory followed by the value of @RemoteServiceRelativePath, and that if the module has a rename-to the renamed value must be used because it is the real directory in the WAR. The Bookshelf’s module is renamed to bookshelf and the catalog interface carries @RemoteServiceRelativePath("catalog"), so the mapping is /bookshelf/catalog. The same file registers the filters and servlets that the later sections explain:

The Bookshelf web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
  version="5.0">

  <context-param>
    <param-name>gwt.xsrf.session_cookie_name</param-name>
    <param-value>JSESSIONID</param-value>
  </context-param>

  <filter>
    <filter-name>sessionFilter</filter-name>
    <filter-class>com.example.bookshelf.server.SessionFilter</filter-class>
  </filter>
  <filter-mapping>
    <filter-name>sessionFilter</filter-name>
    <url-pattern>/bookshelf/xsrf</url-pattern>
  </filter-mapping>

  <filter>
    <filter-name>cacheControl</filter-name>
    <filter-class>com.example.bookshelf.server.CacheControlFilter</filter-class>
  </filter>
  <filter-mapping>
    <filter-name>cacheControl</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

  <servlet>
    <servlet-name>hostPage</servlet-name>
    <servlet-class>com.example.bookshelf.server.HostPageServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>hostPage</servlet-name>
    <url-pattern>/index.html</url-pattern>
    <url-pattern></url-pattern>
  </servlet-mapping>

  <servlet>
    <servlet-name>upload</servlet-name>
    <servlet-class>com.example.bookshelf.server.UploadServlet</servlet-class>
    <multipart-config>
      <max-file-size>1048576</max-file-size>
      <max-request-size>1100000</max-request-size>
      <file-size-threshold>65536</file-size-threshold>
    </multipart-config>
  </servlet>
  <servlet-mapping>
    <servlet-name>upload</servlet-name>
    <url-pattern>/bookshelf/upload</url-pattern>
  </servlet-mapping>

  <servlet>
    <servlet-name>catalogServlet</servlet-name>
    <servlet-class>com.example.bookshelf.server.CatalogServiceImpl</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>catalogServlet</servlet-name>
    <url-pattern>/bookshelf/catalog</url-pattern>
  </servlet-mapping>

  <servlet>
    <servlet-name>xsrfServlet</servlet-name>
    <servlet-class>com.google.gwt.user.server.rpc.jakarta.XsrfTokenServiceServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>xsrfServlet</servlet-name>
    <url-pattern>/bookshelf/xsrf</url-pattern>
  </servlet-mapping>

  <servlet>
    <servlet-name>booksRest</servlet-name>
    <servlet-class>com.example.bookshelf.server.BooksRestServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>booksRest</servlet-name>
    <url-pattern>/api/books</url-pattern>
  </servlet-mapping>

  <welcome-file-list>
    <welcome-file>index.html</welcome-file>
  </welcome-file-list>
</web-app>

Points worth reading in that file:

  • gwt.xsrf.session_cookie_name is the context parameter that XsrfTokenServiceServlet reads to know which cookie identifies the session; XSRF protection of RPC calls is covered by the XSRF, RPC & Server Security page and is mentioned here only because it is configured here.

  • The URL pattern with the empty string maps the host page servlet to the context root, the /index.html pattern to the file name, and the welcome-file-list makes a request for / reach it.

  • <multipart-config> is what turns on Part handling and its size limits for the upload servlet; the numbers are bytes.

  • The session filter is mapped to the token servlet only. The first version of the Bookshelf mapped its session filter to /*, and the effect of that choice on caching is shown in the caching section.

Declaring servlets in the module XML (<servlet path=... class=.../>) is an older mechanism. The official help page about servlet mappings says that modern GWT applications use it only for tests and that application code should not rely on it; the web.xml above is the single source of truth for a development setup with a servlet container and for production. A second help topic, "Web App Classpath Problem", explains the warning that the development server of that time (hosted mode) printed when server classes came from the system classpath instead of WEB-INF; the property gwt.nowarn.webapp.classpath silences it, and the fix is to put the classes in WEB-INF/classes and the jars in WEB-INF/lib, which is exactly what a WAR does.

Jetty and Tomcat: the WAR

The WAR is the unit both containers understand, and bookshelf-server already produces it with mvn package. To try it without installing anything, the Jetty Maven plugin that the archetype pins runs the finished WAR:

Running the packaged WAR on Jetty
mvn -B -pl bookshelf-server org.eclipse.jetty.ee10:jetty-ee10-maven-plugin:12.1.10:run-war -Djetty.http.port=8936
[INFO] Started oejs.ServerConnector@...{HTTP/1.1, (http/1.1)}{0.0.0.0:8936}
[INFO] Started oejs.Server@...{STARTING}[12.1.10,sto=0] @381ms

This run replaces the jetty:run of the development setup (which serves the code server’s output, see Development Mode & Debugging) with the real packaged files, so it is the closest local test of what a container will see. Two default behaviours of the plugin turned up in the first run, before any caching rule was added: static responses had no Cache-Control but an Expires: Thu, 01 Jan 1970 00:00:00 GMT header, and the Bookshelf’s session filter (mapped to /* at that point) added a Set-Cookie: JSESSIONID=... header to the selection script and to every .cache.js file. The first is what this plugin sent in this run and is not something to rely on in production; the second is a deployment bug, because many CDNs and proxies do not cache responses that set cookies unless a rule says so, so check the cookie rules of the CDN you use.

Tomcat

On Tomcat the same WAR is deployed by copying it into the webapps directory, as the official guide’s Tomcat example does (the guide’s application then answers under /MyApp/, named after the WAR). A Tomcat installation was not run for this page. What the page did run is the embedded Tomcat 11.0.24 inside the Spring Boot jar below, with the same servlets and the same policy-file lookup, and a non-root context path (/shelf), which is the case that changes the module base URL. Use Tomcat 10.1 or 11 with gwt-servlet-jakarta; Tomcat 9 needs gwt-servlet and javax.servlet imports in the server classes.

Static files on one server, servlets on another

The Developer Guide’s second example splits the traffic: Apache HTTPD serves everything except WEB-INF/ from its document root and proxies only the RPC URLs to Tomcat with ProxyPass and ProxyPassReverse, so that the browser sees a single origin. Its reason for deploying the entire WAR to Tomcat is the one found above: servlets may call ServletContext.getResource, and GWT-RPC servlets always need the generated policy file. The example’s check is still good practice: a GET of the RPC URL on both hosts should answer 405, and a 404 points at the left-hand side of the mapping while "Bad Gateway" points at the right-hand side. The Bookshelf shows the same 405 (the servlet only handles POST):

curl -s -o /dev/null -D - http://localhost:8936/bookshelf/catalog | grep -iE "^HTTP"
HTTP/1.1 405 Method Not Allowed

A request that does reach the servlet but is not a GWT-RPC request is rejected by the servlet, which is what a firewall or proxy that rewrites the Content-Type produces:

curl -s -D - -X POST -H 'Content-Type: application/json' -d '{}' http://localhost:8936/bookshelf/catalog
HTTP/1.1 500 Server Error
Content-Type: text/plain

The call failed on the server; see server log for details
Exception while dispatching incoming RPC call
jakarta.servlet.ServletException: Content-Type was 'application/json'. Expected 'text/x-gwt-rpc'.
    at com.google.gwt.user.server.rpc.jakarta.RPCServletUtils.checkContentTypeIgnoreCase (RPCServletUtils.java:479)

A prefix in front of the application

A reverse proxy or an ingress that publishes the application under a prefix (/shelf/) and strips it before forwarding breaks the policy lookup, although the application itself looks healthy: the browser’s module base URL contains the prefix, the application’s context path does not, and the servlet then looks for /shelf/bookshelf/<hash>.gwt.rpc. The run below sends the real token request of the Bookshelf with the module base rewritten to such a path (the request body was captured from Chrome; only the first token changed):

7|0|4|http://localhost:8936/cdn/v42/bookshelf/|TOKEN_POLICY_HASH|com.google.gwt.user.client.rpc.XsrfTokenService|getNewXsrfToken|1|2|3|4|0|
xsrfServlet: ERROR: The serialization policy file '/cdn/v42/bookshelf/TOKEN_POLICY_HASH.gwt.rpc' was not found; did you forget to include it in this deployment?
xsrfServlet: WARNING: Failed to get the SerializationPolicy 'TOKEN_POLICY_HASH' for module 'http://localhost:8936/cdn/v42/bookshelf/'; a legacy, 1.3.3 compatible, serialization policy will be used.  You may experience SerializationExceptions as a result.

The way to avoid it is to keep the prefix in the application: give the application the matching context path (Spring Boot: server.servlet.context-path=/shelf, which the Bookshelf was run with and which worked, because the servlet strips /shelf from the module path before it builds the file name) or do not strip the prefix in the proxy. When the policy files really live somewhere else, the servlet has a hook: the protected method doGetSerializationPolicy(HttpServletRequest, String moduleBaseURL, String strongName) of RemoteServiceServlet (the Jakarta variant has the same signature) is what the lookup calls, and overriding it is the supported way to load policies from another location. Overriding it widens what the server will deserialise, so the source of the policy must be as trusted as the application’s own WAR.

Spring Boot

A Spring Boot application replaces the container, the WAR and web.xml with one executable jar and Java configuration, and it serves the compiled module as static content. Two things behave differently from a WAR, and the second one is the trap.

  1. Servlets and filters are beans: ServletRegistrationBean and FilterRegistrationBean register what web.xml registered, server.servlet.context-parameters.* properties replace <context-param>, and the MultipartConfigElement of the registration replaces <multipart-config>.

  2. Static content is served by Spring MVC’s resource handler from the classpath (/static, /public, /resources, /META-INF/resources) or from the root of the servlet context, as the reference says, but the servlet context is a different lookup: in the runs below it found META-INF/resources of a dependency jar and did not find it in the application’s own BOOT-INF/classes of the executable jar.

The Bookshelf’s application class registers the same five servlets and two filters as the web.xml above (Spring Boot 4.1.1, starter spring-boot-starter-webmvc):

BookshelfApplication.java: servlets, filters and the welcome page as beans
package com.example.bookshelf.boot;

import com.example.bookshelf.server.BooksRestServlet;
import com.example.bookshelf.server.CacheControlFilter;
import com.example.bookshelf.server.CatalogServiceImpl;
import com.example.bookshelf.server.HostPageServlet;
import com.example.bookshelf.server.SessionFilter;
import com.example.bookshelf.server.UploadServlet;
import com.google.gwt.user.server.rpc.jakarta.XsrfTokenServiceServlet;
import jakarta.servlet.MultipartConfigElement;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class BookshelfApplication implements WebMvcConfigurer {
  public static void main(String[] args) {
    SpringApplication.run(BookshelfApplication.class, args);
  }

  @Bean
  ServletRegistrationBean<CatalogServiceImpl> catalogServlet() {
    return new ServletRegistrationBean<>(new CatalogServiceImpl(), "/bookshelf/catalog");
  }

  @Bean
  ServletRegistrationBean<XsrfTokenServiceServlet> xsrfServlet() {
    return new ServletRegistrationBean<>(new XsrfTokenServiceServlet(), "/bookshelf/xsrf");
  }

  @Bean
  ServletRegistrationBean<HostPageServlet> hostPage() {
    return new ServletRegistrationBean<>(new HostPageServlet(), "/index.html");
  }

  @Bean
  ServletRegistrationBean<BooksRestServlet> booksRest() {
    return new ServletRegistrationBean<>(new BooksRestServlet(), "/api/books");
  }

  @Bean
  ServletRegistrationBean<UploadServlet> upload() {
    ServletRegistrationBean<UploadServlet> bean = new ServletRegistrationBean<>(new UploadServlet(), "/bookshelf/upload");
    // location, maxFileSize, maxRequestSize, fileSizeThreshold: the same numbers as <multipart-config> in web.xml
    bean.setMultipartConfig(new MultipartConfigElement("", 1_048_576L, 1_100_000L, 65_536));
    return bean;
  }

  @Bean
  FilterRegistrationBean<SessionFilter> sessionFilter() {
    FilterRegistrationBean<SessionFilter> bean = new FilterRegistrationBean<>(new SessionFilter(), xsrfServlet());
    bean.addUrlPatterns("/bookshelf/xsrf");
    return bean;
  }

  @Bean
  FilterRegistrationBean<CacheControlFilter> cacheControl() {
    FilterRegistrationBean<CacheControlFilter> bean = new FilterRegistrationBean<>(new CacheControlFilter());
    bean.addUrlPatterns("/*");
    return bean;
  }

  /** The context root "/" is the host page servlet. */
  @Override
  public void addViewControllers(ViewControllerRegistry registry) {
    registry.addViewController("/").setViewName("forward:/index.html");
  }
}

application.properties holds the two settings that cannot be code:

server.port=8936
server.servlet.context-parameters.gwt.xsrf.session_cookie_name=JSESSIONID

Spring Security, if it is added, brings its own request filters and its own CSRF protection for state-changing requests, including the RPC POST requests; that interaction was not tested here, and the Spring Security page of the Spring Boot section is the place to start.

Where the module directory goes

The first attempt copied the client’s WAR contents into target/classes/META-INF/resources of the Boot module (a maven-dependency-plugin unpack execution in prepare-package). The jar built, started, served every file and showed an empty catalog: the server log held the policy error of the first pitfall. The cause is the one in the second point above. Spring MVC found the files on the classpath and sent them to the browser, so bookshelf.nocache.js and the .gwt.rpc files all answered 200, but RemoteServiceServlet asks the servlet context, and the servlet context of the embedded Tomcat does not expose BOOT-INF/classes/META-INF/resources of an executable jar:

xsrfServlet: ERROR: The serialization policy file '/bookshelf/TOKEN_POLICY_HASH.gwt.rpc' was not found; did you forget to include it in this deployment?
xsrfServlet: WARNING: Failed to get the SerializationPolicy 'TOKEN_POLICY_HASH' for module 'http://localhost:8936/bookshelf/'; a legacy, 1.3.3 compatible, serialization policy will be used.  You may experience SerializationExceptions as a result.
Exception while dispatching incoming RPC call

(The same two lines, and the SerializationException that follows them, appear when the policy files are deleted from a deployed WAR; that run is in the pitfalls table.) The fix that needs no code is to package the module directory as the META-INF/resources of its own jar and make that jar a dependency of the application. The bookshelf-assets module does only that:

bookshelf-assets/pom.xml: the module directory as a jar
  <dependencies>
    <dependency>
      <groupId>${project.groupId}</groupId>
      <artifactId>bookshelf-client</artifactId>
      <version>${project.version}</version>
      <type>war</type>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-dependency-plugin</artifactId>
        <version>3.8.1</version>
        <executions>
          <execution>
            <id>unpack-gwt-output</id>
            <phase>prepare-package</phase>
            <goals><goal>unpack</goal></goals>
            <configuration>
              <artifactItems>
                <artifactItem>
                  <groupId>${project.groupId}</groupId>
                  <artifactId>bookshelf-client</artifactId>
                  <version>${project.version}</version>
                  <type>war</type>
                  <outputDirectory>${project.build.outputDirectory}/META-INF/resources</outputDirectory>
                  <excludes>META-INF/**,WEB-INF/**</excludes>
                </artifactItem>
              </artifactItems>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

and the Boot module depends on it, on the server module’s classes (the WAR plugin’s attachClasses option produces the classes jar) and on nothing from the client:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
  <groupId>${project.groupId}</groupId>
  <artifactId>bookshelf-server</artifactId>
  <version>${project.version}</version>
  <classifier>classes</classifier>
  <exclusions>
    <exclusion>
      <groupId>${project.groupId}</groupId>
      <artifactId>bookshelf-client</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>${project.groupId}</groupId>
  <artifactId>bookshelf-assets</artifactId>
  <version>${project.version}</version>
</dependency>

With that layout the jar (30,639,967 bytes, mostly Spring and the 9.6 MiB RPC jar) starts, the catalog loads and the log has no policy error. Both the server classes and the RPC library are on the classpath of the application, so nothing else is needed:

mvn -B -pl bookshelf-boot -am package -DskipTests
java -jar bookshelf-boot/target/bookshelf-boot-1.0-SNAPSHOT.jar
title = Bookshelf - guest
rows  = Estantería | Catálogo | Administración | 1-10 of 25 | Título Autor Ejemplares | The Pragmatic Programmer | Andrew Hunt | ...

(The two lines are the result of loading / in headless Chrome and reading the page title and the visible text: the host page servlet answered, the module loaded, the XSRF token and the first catalog page came through RPC. The text is Spanish because the Chrome of the machine sends a Spanish Accept-Language, which the host page servlet honours.) With --server.servlet.context-path=/shelf the same jar served the same page at /shelf/, the module base URL became http://localhost:8936/shelf/bookshelf/ and the catalog loaded, with no policy error.

Spring Boot’s spring.web.resources.chain.strategy.content.enabled (content-hash version strategy) rewrites resource URLs that Spring generates in templates; it does nothing for GWT, whose file names are already hashes and are written into bookshelf.nocache.js by the compiler. The plain GET handlers of the other pages of the Spring Boot section (REST controllers, security, configuration) work next to these servlets unchanged; the starting points are Getting Started with Spring Boot, REST APIs with Spring MVC and WebFlux and Spring Security.

Dynamic host pages

The host page is the only part of a GWT application that is HTML you write, and it can be produced by a servlet instead of a file. The official article "Dynamic Host Page" gives the reasons. The module’s JavaScript is large, so an application that shows nothing to anonymous visitors should not send it to them; and an RPC call in onModuleLoad to fetch the user’s name costs a second round trip right after the first. It lists four techniques: a static host page with an RPC call, a security constraint in web.xml, a servlet as host page and a template-based host page (JSP in the article), and its recommendation is to authenticate before the selection script is served and to write the initial data into the page. Any template engine does the job; the Thymeleaf section of this site covers one, and the servlet below needs none.

The Bookshelf’s HostPageServlet does three things: it creates the session (the JSESSIONID cookie that the XSRF token servlet derives its token from), it writes a CSRF token for plain endpoints and the user and locale as JSON, and it sets the response headers that a per-user page needs:

HostPageServlet.java
package com.example.bookshelf.server;

import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;

/** Serves the GWT host page with the user, the locale and a CSRF token already in it. */
public class HostPageServlet extends HttpServlet {
  static final String CSRF_ATTRIBUTE = "bookshelf.csrf";
  private static final SecureRandom RANDOM = new SecureRandom();

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    HttpSession session = req.getSession(true);          // also creates the JSESSIONID the XSRF servlet needs
    String csrf = (String) session.getAttribute(CSRF_ATTRIBUTE);
    if (csrf == null) {
      byte[] bytes = new byte[32];
      RANDOM.nextBytes(bytes);
      csrf = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
      session.setAttribute(CSRF_ATTRIBUTE, csrf);
    }
    // without an Accept-Language header getLocale() is the server's default locale, so test the header itself
    String accept = req.getHeader("Accept-Language");
    String locale = accept != null && "es".equals(req.getLocale().getLanguage()) ? "es" : "en";
    String user = req.getRemoteUser() == null ? "guest" : req.getRemoteUser();

    resp.setContentType("text/html");
    resp.setCharacterEncoding("UTF-8");
    resp.setHeader("Cache-Control", "no-store");         // per-user data and a token: never cache
    resp.setHeader("Vary", "Accept-Language");           // the locale below depends on it
    resp.getWriter().write("""
        <!doctype html>
        <html lang="%s">
        <head>
          <meta charset="UTF-8">
          <title>Bookshelf</title>
          <meta name="gwt:property" content="locale=%s">
          <meta name="csrf-token" content="%s">
          <script type="application/json" id="bookshelf-initial-data">{"user":%s,"locale":"%s"}</script>
          <script src="bookshelf/bookshelf.nocache.js"></script>
        </head>
        <body>
          <noscript>Bookshelf needs JavaScript.</noscript>
        </body>
        </html>
        """.formatted(locale, locale, ScriptJson.htmlAttribute(csrf), ScriptJson.quote(user), locale));
  }
}

The page it sends (the token replaced by the placeholder CSRF_TOKEN_PLACEHOLDER; the real value is 43 random URL-safe characters per session; the Accept-Language header was en-US):

The host page written by HostPageServlet
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Bookshelf</title>
  <meta name="gwt:property" content="locale=en">
  <meta name="csrf-token" content="CSRF_TOKEN_PLACEHOLDER">
  <script type="application/json" id="bookshelf-initial-data">{"user":"guest","locale":"en"}</script>
  <script src="bookshelf/bookshelf.nocache.js"></script>
</head>
<body>
  <noscript>Bookshelf needs JavaScript.</noscript>
</body>
</html>

Some decisions in that servlet are worth stating:

  • The locale is chosen on the server from the Accept-Language header and written as the gwt:property meta tag, so the selection script picks the right permutation without a second request (see Internationalization for the locale property); Vary: Accept-Language tells caches that the page depends on the header. Without the header, getLocale() returns the server’s default locale (the development machine’s Spanish in this run), which is why the servlet tests the header itself.

  • Cache-Control: no-store: the page contains a per-session token and per-user data. The heavy files stay cacheable because they are separate requests.

  • The data block is type="application/json", which no browser executes, so it works under a Content Security Policy that forbids inline scripts. The official article instead assigns a global variable in an inline script, which needs a nonce or 'unsafe-inline'.

  • Nothing user-controlled is concatenated into HTML or JavaScript without escaping, which is the next point.

Reading the data on the client

The client reads the data block and the meta tag in HostPage, with JSONParser.parseStrict (module com.google.gwt.json.JSON, which the User module does not include) and MetaElement; if the block is absent it falls back to the official article’s technique, Dictionary, which reads a global JavaScript object:

HostPage.java: reading the host page data on the client
package com.example.bookshelf.client;

import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.MetaElement;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.i18n.client.Dictionary;
import com.google.gwt.json.client.JSONObject;
import com.google.gwt.json.client.JSONParser;
import com.google.gwt.json.client.JSONString;
import java.util.MissingResourceException;

/** Data the server wrote into the host page: a JSON data block (preferred) or a global variable. */
public final class HostPage {
  private static String user = "unknown";
  private static String csrfToken = "";

  private HostPage() { }

  public static void read() {
    csrfToken = meta("csrf-token");
    Element block = Document.get().getElementById("bookshelf-initial-data");
    if (block != null) {
      JSONObject data = JSONParser.parseStrict(block.getInnerText()).isObject();
      JSONString name = data.get("user").isString();
      user = name == null ? "unknown" : name.stringValue();
      return;
    }
    try {
      user = Dictionary.getDictionary("bookshelfInfo").get("user");   // var bookshelfInfo = {...}
    } catch (MissingResourceException e) {
      user = "unknown";
    }
  }

  public static String user() { return user; }

  public static String csrfToken() { return csrfToken; }

  private static String meta(String name) {
    NodeList<Element> metas = Document.get().getElementsByTagName("meta");
    for (int i = 0; i < metas.getLength(); i++) {
      MetaElement meta = MetaElement.as(metas.getItem(i));
      if (name.equals(meta.getName())) {
        return meta.getContent();
      }
    }
    return "";
  }
}

App.onModuleLoad calls HostPage.read() first and sets the document title to "Bookshelf - " + HostPage.user(). Chrome confirmed both paths: / (data block) gave the title Bookshelf - guest, and /inline.html, a static page that only has <script>var bookshelfInfo = { "user" : "inline-guest" };</script>, gave Bookshelf - inline-guest through Dictionary. A Dictionary name that is not defined throws MissingResourceException with the message Cannot find JavaScript object with the name 'bookshelfInfo', and one that is not an object throws '...' is not a JavaScript object and cannot be used as a Dictionary.

Escaping: the article’s example is not safe

The article’s example writes the user’s e-mail address into the page as "email" : " followed by the raw value. With a value such as x</script><script>alert(1)</script> that closes the script element and starts another, so any value that comes from a user must be escaped for the context it is written into. For JSON in a script element that means escaping <, > and & (as <, >, &) and the two line separators U+2028 and U+2029, as well as the usual quote and backslash:

ScriptJson.java: escaping for script elements and attributes
package com.example.bookshelf.server;

/** JSON string literals that are safe inside an HTML script element. */
final class ScriptJson {
  private ScriptJson() { }

  static String quote(String s) {
    StringBuilder sb = new StringBuilder("\"");
    for (int i = 0; i < s.length(); i++) {
      char c = s.charAt(i);
      switch (c) {
        case '"': sb.append("\\\""); break;
        case '\\': sb.append("\\\\"); break;
        case '<': sb.append("\\u003c"); break;    // no "</script" and no "<!--" can survive
        case '>': sb.append("\\u003e"); break;
        case '&': sb.append("\\u0026"); break;
        case '
': sb.append("\\u2028"); break;
        case '
': sb.append("\\u2029"); break;
        default:
          if (c < 0x20) {
            sb.append(String.format("\\u%04x", (int) c));
          } else {
            sb.append(c);
          }
      }
    }
    return sb.append('"').toString();
  }

  static String htmlAttribute(String s) {
    return s.replace("&", "&amp;").replace("\"", "&quot;").replace("<", "&lt;").replace(">", "&gt;");
  }
}

The unit test ScriptJsonTest (part of the 6 server tests that pass) prints what the two approaches write for the hostile value:

naive : {"user":"x</script><script>alert(1)</script><!--"}
quoted: {"user":"x</script><script>alert(1)</script><!--"}

On the client side the data goes into the page with setText, never setHTML, and the SafeHtml & XSS page covers the cases where HTML must be built.

CSRF tokens in the host page

There are two kinds of token and they are not interchangeable.

  • GWT-RPC’s own protection: a service that extends XsrfProtectedService rejects calls without a token that the XsrfTokenService handed out for the current session cookie. The Bookshelf’s ClientFactory.initXsrf fetches it before the first call (see the log of the pitfalls section for the case where this fails). This is the subject of the XSRF, RPC & Server Security page.

  • A token for plain servlets: the upload servlet above is not an RPC service, so it needs its own. The host page servlet generates 32 random bytes with SecureRandom, stores them in the session and writes them into the csrf-token meta tag; the form sends them back as a field and the servlet compares them in constant time with MessageDigest.isEqual.

The token is only as good as the session around it: the cookie flags (HttpOnly, Secure, SameSite) are container configuration. In this run Spring Boot’s Tomcat sent JSESSIONID=...; Path=/; HttpOnly and the Jetty plugin sent JSESSIONID=...; Path=/ without HttpOnly.

Caching, CDNs and static hosting

GWT names files so that the caching rule can be stated per file, and the Developer Guide’s FAQ gives the reasoning. The .cache.js files are named by the MD5 of their contents, so they never change under the same name and browsers may keep them; the .nocache.js file is regenerated under the same name on every compile and must not be reused without asking the server. (The FAQ speaks of .cache.html and .nocache.html, which belong to the iframe linker of earlier versions (see Linkers & Bootstrap); with the default linker of 2.13.1 the files are .cache.js and .nocache.js, and the principle is unchanged.)

Table 3. Caching rules for the compiler output
Files Cache-Control Reason

*.cache.js, deferredjs/**, *.gwt.rpc

public, max-age=31536000, immutable

The name contains a hash of the content. immutable (RFC 8246) tells the browser not to revalidate on reload.

*.nocache.js, compilation-mappings.txt, theme CSS and images under gwt/

no-cache

Fixed names. no-cache (RFC 9111) lets caches store the response but forces revalidation before every reuse, which costs a 304 and a few hundred bytes when nothing changed. no-store would also work and is what the host page uses, because it carries per-user data.

The host page

no-store for a servlet page; no-cache for a static one

It names the selection script, so a stale host page is a stale application.

RPC and REST responses

Set by the servlet

Not static files.

A servlet filter that applies the table works in any container, and Spring Boot registers it as a bean (see cacheControl() above):

CacheControlFilter.java
package com.example.bookshelf.server;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

/** Sets Cache-Control for the compiler output: hashed files forever, everything else revalidated. */
public class CacheControlFilter implements Filter {
  static final String FOREVER = "public, max-age=31536000, immutable";
  static final String REVALIDATE = "no-cache";

  /** Returns the Cache-Control value for a context-relative path, or null to leave the response alone. */
  static String policyFor(String path) {
    if (path.endsWith(".cache.js") || path.endsWith(".gwt.rpc")) {
      return FOREVER;          // file name contains the hash of the content
    }
    if (path.endsWith(".nocache.js") || path.endsWith(".html") || path.endsWith(".css")
        || path.endsWith(".png") || path.endsWith(".gif") || path.endsWith(".txt")
        || path.endsWith(".js")) {
      return REVALIDATE;       // fixed name, content changes with each release
    }
    return null;               // servlets (RPC, REST, upload) set their own headers
  }

  @Override
  public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
      throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    String path = request.getRequestURI().substring(request.getContextPath().length());
    String policy = policyFor(path);
    if (policy != null) {
      ((HttpServletResponse) resp).setHeader("Cache-Control", policy);
    }
    chain.doFilter(req, resp);
  }
}

The headers the Bookshelf sent with the filter in place (the Jetty and the Spring Boot runs gave the same values; the hashes shortened):

Table 4. Response headers observed with the filter in place
Request Cache-Control Other headers

GET /

no-store

Vary: Accept-Language, a Set-Cookie

GET /bookshelf/bookshelf.nocache.js

no-cache

Last-Modified; no Set-Cookie

GET /bookshelf/<hash>.cache.js

public, max-age=31536000, immutable

Last-Modified

GET /bookshelf/<hash>.gwt.rpc

public, max-age=31536000, immutable

Last-Modified; Content-Type: application/octet-stream on Spring Boot

GET /bookshelf/gwt/clean/clean.css

no-cache

Last-Modified

A conditional request for the selection script, with the Last-Modified value it had sent, got HTTP/1.1 304 Not Modified. Chrome behaved as the table promises: on a second visit it asked for bookshelf.nocache.js and clean.css and got 304 for both, and it took <hash>.cache.js from its cache without a request:

--- first visit (empty browser cache)
GET   200             /
GET   200             /bookshelf/bookshelf.nocache.js
GET   200             /bookshelf/gwt/clean/clean.css
GET   200             /bookshelf/<hash>.cache.js
POST  200             /bookshelf/xsrf
POST  200             /bookshelf/catalog
--- second visit (normal navigation)
GET   200             /?again=1
GET   304             /bookshelf/bookshelf.nocache.js
GET   304             /bookshelf/gwt/clean/clean.css
GET   200  from cache /bookshelf/<hash>.cache.js
POST  200             /bookshelf/xsrf
POST  200             /bookshelf/catalog

Rolling out a new release

Because the hashed files never change under a name, a new release adds new names and leaves old ones unused. Three rules follow from that and from the policy-file lookup.

  • Upload the hashed files first and the nocache.js last, so that no browser ever receives a selection script that points to a file that does not exist yet.

  • Keep the previous release’s hashed files and .gwt.rpc files for as long as old browser tabs may be open. A tab that still runs the old build keeps sending the old policy name; if the new WAR has no such file the server logs the "was not found" error above and uses the legacy policy. A change to a service signature gives the old tab IncompatibleRemoteServiceException with the text "This application is out of date, please click the refresh button on your browser." (from the exception class), which the client should turn into a reload prompt.

  • Do not let a session filter touch static paths. In the first run the filter mapped to /* put Set-Cookie on every static response; a CDN will not store those. The Bookshelf’s web.xml and BookshelfApplication map it to the token servlet only.

A CDN or static server on another origin

The module base URL is the URL of the script that was loaded. That has a consequence the CDN case runs into: if the host page loads http://localhost:8937/bookshelf/bookshelf.nocache.js from another origin, GWT.getModuleBaseURL() is that origin, and every RPC call is sent there. The run (host page on port 8936, a static file server with a copy of the module directory on port 8937):

REQUEST POST http://localhost:8937/bookshelf/xsrf
REQUEST OPTIONS http://localhost:8937/bookshelf/xsrf
FAILED net::ERR_FAILED {"corsError":"PreflightMissingAllowOriginHeader","failedParameter":""}
LOG error Access to XMLHttpRequest at 'http://localhost:8937/bookshelf/xsrf' from origin 'http://localhost:8936' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

(The page loaded, the script ran and the title was set, which shows that the module can be loaded from another origin; only the RPC call failed.) There are two ways out, and the first is simpler:

  1. One origin, path-based routing. Put the CDN or the reverse proxy in front of both: the module directory and the static files come from object storage or the CDN’s cache, while the RPC paths (/bookshelf/catalog, /bookshelf/xsrf, /bookshelf/upload) and the host page are routed to the application. The browser sees one origin, no CORS is involved, and the module base path is the one the application also has. The application must still be able to open the .gwt.rpc files at that path through its servlet context, which means they are also in the WAR or the jar, or that doGetSerializationPolicy is overridden.

  2. Two origins. Point each service at the API origin with ServiceDefTarget.setServiceEntryPoint(...) and configure CORS on the server, including the preflight for the headers GWT-RPC sends (X-GWT-Module-Base and X-GWT-Permutation) and the content type text/x-gwt-rpc. The cookie and token rules that follow from a cross-origin call are those of the CORS page of the Security section of this site, What is CORS?. This variant was not built here.

The cloud sections of this site cover the storage and CDN products themselves. For the static half: S3 Security and Static Hosting with CloudFront and CDN Delivery, Cloud Storage Security and Static Sites with Cloud CDN and Media CDN, and Front Door and CDN. The rules to configure in all of them are the table above; most CDNs let a rule override the Cache-Control of the origin, and most can compress.

Compression

The compiled JavaScript is the largest file the user downloads, and it compresses well. Spring Boot compresses nothing by default; one property turns it on, and the mime types it compresses by default include text/javascript, application/javascript and text/css, the response must be at least 2 KB, and application/octet-stream (the type of the .gwt.rpc files) is not in the list. With --server.compression.enabled=true and Accept-Encoding: gzip the Bookshelf’s files shrank as follows:

Table 5. Compression measured on Spring Boot
File Plain bytes Gzip bytes Note

bookshelf.nocache.js

7,847

3,431

Content-Encoding: gzip

<hash>.cache.js

269,976

91,131

Content-Encoding: gzip

<hash>.gwt.rpc

1,415

1,415

not compressed: below 2 KB and not a listed mime type

/ (host page)

463

463

not compressed: below 2 KB

The equivalents elsewhere are documented by the containers: Tomcat’s HTTP connector has compression (on), compressibleMimeType (default text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml) and compressionMinSize (default 2048 bytes), and Jetty has GzipHandler, a handler wrapper installed at server level with setMinGzipSize, setExcludedPaths and addIncludedMethods. Neither was run for this page. Two details are specific to GWT:

  • RemoteServiceServlet compresses its own responses. When the request accepts gzip and the payload is longer than 256 bytes it writes Content-Encoding: gzip itself; shouldCompressResponse is the protected method to override. Configure the container’s compression for static content and do not rely on it for RPC.

  • The old .cache.html wrapper existed for compression: the FAQ explains that some browsers did not handle compressed pure JavaScript files correctly. With .cache.js files that reason is gone and every current browser handles it.

Docker packaging

Packaging a GWT application in a container changes little, and the GWT-specific parts are three. The general mechanics are in the Docker section of this site: Dockerfile Fundamentals, Build Cache and Multi-Stage Builds, Java and Spring Boot Images and Image Best Practices and Supply-Chain Security.

  1. Compile in the build stage, ship only the result. The GWT compiler needs a JDK, Maven and gwt-dev (a large dependency), and it takes seconds to minutes; the runtime image needs a JRE and one jar. A multi-stage build keeps the compiler out of the image.

  2. The JVM flag for exceptions. On Java 17 and later a server-side exception that has to be serialised makes the RPC call fail unless the JVM opens java.lang to the unnamed module (upstream issue gwtproject/gwt#9793). The Bookshelf’s getBook(999) call throws the checked BookNotFoundException; without the flag Chrome received 500 text/plain and with it 200 application/json. The flag therefore belongs in the image’s ENTRYPOINT.

  3. Writable storage for uploads and a path prefix are configuration of the container, not of GWT: a volume for the upload directory and, if an ingress adds a prefix, the context path discussed above.

java.lang.reflect.InaccessibleObjectException: Unable to make field private java.lang.String java.lang.Throwable.detailMessage accessible: module java.base does not "opens java.lang" to unnamed module @<id>

The Dockerfile that follows is a pattern: it was not built because the Docker daemon was not running when this page was prepared. The jar it packages was run directly with the same --add-opens option on the java -jar command line. The image tags exist on Docker Hub (maven:3.9-eclipse-temurin-21 and eclipse-temurin:21-jre, checked 2026-10-09).

A multi-stage Dockerfile for the Spring Boot jar (not built here)
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/root/.m2 \
    mvn -B -pl bookshelf-boot -am package -DskipTests

FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 bookshelf
USER 10001
WORKDIR /app
COPY --from=build /src/bookshelf-boot/target/bookshelf-boot-1.0-SNAPSHOT.jar app.jar
ENV SERVER_PORT=8080
EXPOSE 8080
ENTRYPOINT ["java", "--add-opens", "java.base/java.lang=ALL-UNNAMED", "-jar", "app.jar"]

A WAR in a Tomcat or Jetty base image works the same way (copy it to webapps/ROOT.war or the Jetty equivalent); the Spring Boot jar was chosen because it was the variant that could be run for this page. Behind Kubernetes the usual pieces apply: an Ingress routes /bookshelf/catalog, /bookshelf/xsrf and the host page to the application (and, optionally, the module directory to a static origin, as in the CDN section), health probes decide readiness, and a rolling update of a Deployment is exactly the situation of the rollout rules above: during the update two versions of the application answer, and an open tab of the old version must still find its policy file. On the managed container platforms see Cloud Run, Container Apps and App Service.

The App Engine tutorial, as history

The official deployment page ends with "Deploying on Google App Engine", and the tutorial "Deploy to GAE" belongs to the StockWatcher series. It is worth reading once to see how deployment was done when GWT 2.x was new, and it should not be followed today. What the tutorial prescribes, from the page itself: the Google Plugin for Eclipse or an Ant build.xml, a hand-written WEB-INF/appengine-web.xml with an <application> element for the application ID, the App Engine development server started through com.google.appengine.tools.development.gwt.AppEngineLauncher as GWT’s development-mode server, appcfg.sh update war to upload, Java source level 1.5 in the javac task, and the datastore with JDO and DataNucleus byte-code enhancement. The dynamic host page article uses the App Engine Users API for the login check in the same way.

What carries over is the structure: a WAR (or a jar) with the module directory, web.xml, and servlets. What does not: the toolchain, the descriptor, the upload command and the storage API. Google’s current documentation of the Java runtime for App Engine’s standard environment describes a managed Java runtime (its text gives Java 21 as the example version) that updates patch releases automatically, and its tooling is described there, not in the GWT guide. The Spring Boot jar or the Docker image of this page is what such a platform would be given; no App Engine deployment was run for this page.

Server-side file upload

GWT has no upload widget of its own beyond the browser’s: FileUpload is a file input and FormPanel submits a form, with a file, as a classic multipart POST into a hidden iframe. The Bookshelf uses it to upload a cover image from the admin panel, which is the part of the page reached through GWT.runAsync (a code-split fragment, see Code Splitting):

AdminPanel.java: FormPanel, FileUpload and Hidden
package com.example.bookshelf.client.view;

import com.example.bookshelf.client.HostPage;
import com.google.gwt.core.client.GWT;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.FileUpload;
import com.google.gwt.user.client.ui.FormPanel;
import com.google.gwt.user.client.ui.Hidden;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.VerticalPanel;

/** Admin area; reached only through GWT.runAsync, so it compiles into its own JavaScript fragment. */
public class AdminPanel extends Composite {
  public AdminPanel() {
    VerticalPanel p = new VerticalPanel();
    p.add(new Label("Admin area (loaded on demand)"));

    FormPanel form = new FormPanel();
    form.setAction(GWT.getModuleBaseURL() + "upload");          // .../bookshelf/upload
    form.setMethod(FormPanel.METHOD_POST);
    form.setEncoding(FormPanel.ENCODING_MULTIPART);              // required for FileUpload

    FileUpload file = new FileUpload();
    file.setName("file");                                        // the part name the servlet asks for
    Button send = new Button("Upload cover");
    Label status = new Label();
    status.getElement().setId("upload-status");

    VerticalPanel fields = new VerticalPanel();
    fields.add(new Hidden("_csrf", HostPage.csrfToken()));       // from <meta name="csrf-token">
    fields.add(file);
    fields.add(send);
    form.setWidget(fields);

    send.addClickHandler(e -> {
      if (file.getFilename().isEmpty()) {
        status.setText("Choose a file first");
      } else {
        form.submit();
      }
    });
    form.addSubmitCompleteHandler(e -> {
      String results = e.getResults();                           // body.innerHTML of the hidden iframe
      status.setText(results == null ? "unknown" : results);
    });

    p.add(form);
    p.add(status);
    initWidget(p);
  }
}

What each piece does, with the facts from the 2.13.1 source:

  • FormPanel.setEncoding(ENCODING_MULTIPART) and setMethod(METHOD_POST) are required for a file input to be sent; FormPanel also has ENCODING_URLENCODED and METHOD_GET.

  • FileUpload.setName gives the part its name. A file input without a name is not sent by browsers, which is the usual reason for a servlet that "receives nothing".

  • Hidden carries extra fields. The CSRF token is one.

  • The panel must stay attached until the submission completes, as the Javadoc of submit() and of SubmitHandler says; removing it from the page early means no event.

  • SubmitCompleteEvent.getResults() is the innerHTML of the hidden iframe’s body (the implementation reads iframe.contentWindow.document.body.innerHTML), or null if it could not be read, for instance after a submission to another domain.

That last point decides what the server should return. Chrome renders a text/plain response in a pre element, so the status label showed:

<pre style="word-wrap: break-word; white-space: pre-wrap;">OK UPLOAD_ID_PLACEHOLDER</pre>

The sample shows the wrapper as text with setText on purpose. A real client should match the result against a strict pattern (for instance OK ([0-9a-f-]{36})) and never put server output into the page as HTML. (The identifier is a random UUID that the server generated; it is replaced by a placeholder here.)

sequenceDiagram participant B as Browser participant H as HostPageServlet participant U as UploadServlet B->>H: GET / with the session cookie H-->>B: host page with the csrf-token meta tag B->>U: POST bookshelf/upload, multipart with _csrf and file U->>U: container checks maxFileSize and maxRequestSize U->>U: check session, token, size, magic bytes U-->>B: 200 OK id, or 4xx ERROR code in text/plain B->>B: SubmitCompleteEvent, show the matched text with setText

The servlet

The upload servlet is the Jakarta Servlet Part API with the limits of <multipart-config>; no extra library is needed (the book’s servlet used the Apache Commons FileUpload library, which was the standard answer when the Servlet API had no multipart support). It does the five things every upload endpoint must do:

UploadServlet.java
/** Stores a cover image or a PDF. The size limits come from the multipart-config element in web.xml. */
public class UploadServlet extends HttpServlet {
  private Path directory;

  @Override
  public void init() throws ServletException {
    try {
      String configured = getServletConfig().getInitParameter("upload.dir");
      directory = (configured == null ? Files.createTempDirectory("bookshelf-uploads") : Path.of(configured))
          .toAbsolutePath().normalize();
      Files.createDirectories(directory);
    } catch (IOException e) {
      throw new ServletException(e);
    }
  }

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
    resp.setContentType("text/plain");
    resp.setCharacterEncoding("UTF-8");
    resp.setHeader("X-Content-Type-Options", "nosniff");
    resp.setHeader("Cache-Control", "no-store");

    // 1. authentication and authorisation would be checked here, on the server (placeholder: any session)
    HttpSession session = req.getSession(false);
    if (session == null) {
      reply(resp, HttpServletResponse.SC_UNAUTHORIZED, "ERROR no-session");
      return;
    }
    // 2. CSRF: the token travels in a form field and must equal the one stored in the session
    Part csrfPart;
    Part file;
    try {
      csrfPart = req.getPart("_csrf");
      file = req.getPart("file");
    } catch (IllegalStateException | ServletException e) {
      // the container enforces max-file-size and max-request-size and reports a part that is too large this way
      log("upload rejected: " + e);
      reply(resp, isTooLarge(e) ? 413 : HttpServletResponse.SC_BAD_REQUEST, isTooLarge(e) ? "ERROR too-large" : "ERROR bad-request");
      return;
    }
    String expected = (String) session.getAttribute(HostPageServlet.CSRF_ATTRIBUTE);
    String actual = text(csrfPart);
    if (expected == null || actual == null || !MessageDigest.isEqual(
        expected.getBytes(StandardCharsets.UTF_8), actual.getBytes(StandardCharsets.UTF_8))) {
      reply(resp, HttpServletResponse.SC_FORBIDDEN, "ERROR bad-csrf");
      return;
    }
    // 3. the file itself
    if (file == null || file.getSize() == 0) {
      reply(resp, HttpServletResponse.SC_BAD_REQUEST, "ERROR no-file");
      return;
    }
    byte[] head = new byte[8];
    int read;
    try (InputStream in = file.getInputStream()) {
      read = in.readNBytes(head, 0, head.length);
    }
    String extension = detect(Arrays.copyOf(head, read));    // from the content, never from the client's file name or type
    if (extension == null) {
      reply(resp, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "ERROR unsupported-type");
      return;
    }
    // 4. a name the server chose: the client's name is never part of a path
    String id = UUID.randomUUID().toString();
    Path target = directory.resolve(id + "." + extension).normalize();
    if (!target.startsWith(directory)) {
      reply(resp, HttpServletResponse.SC_BAD_REQUEST, "ERROR bad-name");
      return;
    }
    try (InputStream in = file.getInputStream()) {
      Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
    }
    log("stored upload " + id + "." + extension + " (" + file.getSize() + " bytes)");
    reply(resp, HttpServletResponse.SC_OK, "OK " + id);
  }

  private static boolean isTooLarge(Throwable t) {
    for (Throwable c = t; c != null; c = c.getCause()) {
      if (c instanceof IllegalStateException) {
        return true;
      }
    }
    return false;
  }

  static String detect(byte[] head) {
    if (startsWith(head, 0x89, 'P', 'N', 'G')) {
      return "png";
    }
    if (startsWith(head, 0xFF, 0xD8, 0xFF)) {
      return "jpg";
    }
    if (startsWith(head, '%', 'P', 'D', 'F', '-')) {
      return "pdf";
    }
    return null;
  }

  private static boolean startsWith(byte[] head, int... magic) {
    if (head.length < magic.length) {
      return false;
    }
    for (int i = 0; i < magic.length; i++) {
      if ((head[i] & 0xFF) != magic[i]) {
        return false;
      }
    }
    return true;
  }

  private static String text(Part part) throws IOException {
    if (part == null || part.getSize() > 256) {
      return null;
    }
    try (InputStream in = part.getInputStream()) {
      return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
  }

  private static void reply(HttpServletResponse resp, int status, String body) throws IOException {
    resp.setStatus(status);
    resp.getWriter().write(body);
  }
}

The rules in that code, in the order of the cheat sheet’s own advice:

  • Authenticate and authorise on the server, before reading the body further. The sample uses "any session" as a placeholder; a real one asks the application’s security layer.

  • CSRF token in a form field, compared in constant time.

  • Limits at two levels: the container’s (<multipart-config> or MultipartConfigElement: file size, whole-request size, and the size above which a part goes to disk) and the application’s (the token field is capped at 256 bytes in text). The container’s limit protects the server from the body; the application’s protects the rest of the code.

  • Never trust the client’s file name or content type. The servlet does not call getSubmittedFileName at all and does not use getContentType; the type is decided from the first bytes of the content (PNG, JPEG and PDF signatures here), and the stored name is a random UUID plus an extension chosen from that type. The path is then checked to be inside the upload directory anyway.

  • Store outside the web root, in a directory that nothing serves, and set X-Content-Type-Options: nosniff on responses; if the files are served back later, serve them from a separate origin or with Content-Disposition: attachment, since an uploaded image may still be a polyglot.

Not covered by the sample, and needed in production: virus scanning, re-encoding images so that a polyglot file is destroyed, per-user quotas and rate limits, and object storage instead of a local directory; the File Storage & Object Stores page covers the last.

What was run

The same two-file upload code ran under both containers, through curl and through Chrome’s file chooser (DevTools DOM.setFileInputFiles on the real FileUpload and a click on the real button):

Table 6. Upload outcomes observed
Request Status Body Why

No session cookie

401

ERROR no-session

getSession(false) is null.

Session, wrong token

403

ERROR bad-csrf

Token differs from the session’s.

Right token, cover.png (16 bytes: the PNG signature and a header start)

200

OK and an id

Stored as <uuid>.png; the log has stored upload <uuid>.png (16 bytes).

Right token, the same file sent with the file name ../../../../tmp/evil.sh and the type text/x-sh

200

OK and an id

Stored under a UUID name; /tmp/evil.sh was not created.

A shell script named evil.png

415

ERROR unsupported-type

The first bytes are not a known signature, whatever the name says.

A 2 MB file with the limit at 1 MB

413

ERROR too-large

Rejected by the container; see the messages in the pitfalls table.

A small file in a request whose total is over maxRequestSize

413

ERROR too-large

The request limit (1,100,000 bytes) is checked as well.

With Spring Boot the limits of <multipart-config> go into the MultipartConfigElement of the ServletRegistrationBean (see upload() above), and they are the ones that apply to a raw servlet: the spring.servlet.multipart.* properties configure Spring MVC’s multipart resolver, not this registration. For files above a few megabytes the classic form has no progress bar and no resume. The browser APIs that offer progress (XMLHttpRequest with its upload object, and FormData with File) are in Elemental2 (elemental2.dom.XMLHttpRequest, XMLHttpRequestUpload, FormData and File exist in elemental2-dom 1.3.2) and need no server change, since the same multipart request reaches the same servlet; direct-to-object-storage uploads with pre-signed URLs take the file out of the application entirely.

Worked example: the deployed Bookshelf

The pieces above, in the order a release goes out, with the commands that were run.

  1. Build everything from clean. mvn -B clean package -DskipTests (reactor summary above). The server tests, including ScriptJsonTest and the type-detection test of the upload servlet, pass: Tests run: 6, Failures: 0, Errors: 0.

  2. Check the WAR. unzip -Z1 shows the module directory, WEB-INF/web.xml and WEB-INF/lib/gwt-servlet-jakarta-2.13.1.jar: 50 files, six <hash>.cache.js, two .gwt.rpc policy files.

  3. Run it on Jetty and check the contract with curl. The host page answered with Cache-Control: no-store; the selection script with no-cache and, for a conditional request, 304; the hashed files with immutable; GET /bookshelf/catalog with 405; a non-RPC POST with 500 and the content-type message in the server log.

  4. Check it in a browser. Headless Chrome loaded /, set the title from the data block, loaded the catalog (1-10 of 25), opened #admin: (the code-split fragment), selected a file in the real FileUpload and clicked the button. The label showed the pre wrapper with OK and an id for a valid file and ERROR unsupported-type for the script. A second visit showed 304 for the selection script and no request for the permutation.

  5. Run it as a Spring Boot jar and repeat the curl checks of step 3 and the upload checks (curl only): the same headers, the same catalog in Chrome, the 413 answers for oversized uploads, and the compression numbers of the table above with the property switched on. The first jar, with the module under BOOT-INF/classes/META-INF/resources, failed the catalog check and is the first pitfall.

  6. Break it on purpose. Delete the .gwt.rpc files from the unpacked WAR that Jetty serves and reload the page: it showed only the application’s shell, without a catalog, and the log held the errors of the first rows of the pitfalls table. Send the token request with a different module base path and the same errors appear.

The check that matters most after a deployment is the last one in miniature: load the page and read the server log for the words serialization policy. It is silent when everything is right, and it is the only place the problem is visible, because the browser sees only 500 and "The call failed on the server; see server log for details".

Pitfalls

Every message in the table was reproduced in the runs of this page, or is taken from the 2.13.1 sources, the official guide or the tool’s documentation, as the third column says.

Table 7. Deployment pitfalls and their messages
Symptom or message Cause Fix Seen in

ERROR: The serialization policy file '/bookshelf/TOKEN_POLICY_HASH.gwt.rpc' was not found; did you forget to include it in this deployment? followed by WARNING: Failed to get the SerializationPolicy ... a legacy, 1.3.3 compatible, serialization policy will be used. You may experience SerializationExceptions as a result.

The .gwt.rpc files are not visible to the servlet context: deleted from the WAR, left out of a static-only deployment, or placed where only Spring MVC sees them (BOOT-INF/classes/META-INF/resources).

Deploy the whole module directory with the application; in Spring Boot put it in the META-INF/resources of a separate jar.

Reproduced (Jetty WAR, Spring Boot jar)

com.google.gwt.user.client.rpc.SerializationException: Type 'com.google.gwt.user.client.rpc.XsrfToken' was not assignable to 'com.google.gwt.user.client.rpc.IsSerializable' and did not have a custom field serializer.For security purposes, this type will not be serialized.

The legacy policy that replaced the missing one allows almost nothing, so even the token type fails. The catalog page stays empty.

Fix the previous row first.

Reproduced

ERROR: The serialization policy file '/cdn/v42/bookshelf/TOKEN_POLICY_HASH.gwt.rpc' was not found; ...

The module base URL in the request is not the path under which the application has its files: a CDN path, a proxy prefix that was stripped, or an old tab.

Same path inside and outside, a context path that matches the prefix, or a doGetSerializationPolicy override.

Reproduced

ERROR: The module path requested, /other/bookshelf/, is not in the same web application as this servlet, /shelf. Your module may not be properly configured or your client and server code maybe out of date.

The module base URL does not start with the servlet’s context path (an application under /shelf serving a page from another path).

Make the host page and the application share the context path.

2.13.1 source

Content-Type was 'application/json'. Expected 'text/x-gwt-rpc'. and a 500 whose body is The call failed on the server; see server log for details

Something that is not the GWT client (a test, a proxy or WAF rewriting the header) posted to an RPC URL.

Send text/x-gwt-rpc; charset=utf-8, or exclude the RPC URLs from the rewriting.

Reproduced

java.lang.reflect.InaccessibleObjectException: Unable to make field private java.lang.String java.lang.Throwable.detailMessage accessible: module java.base does not "opens java.lang" to unnamed module

On Java 17 and later, serialising a server exception needs reflection into java.lang; the client sees 500 and the generic message.

--add-opens java.base/java.lang=ALL-UNNAMED in the container’s JVM options.

Reproduced (500 without the flag, 200 with it)

Access to XMLHttpRequest at 'http://localhost:8937/bookshelf/xsrf' from origin 'http://localhost:8936' has been blocked by CORS policy: ...

The script was loaded from another origin, so the module base URL, and every RPC call, points there.

Route the RPC paths to the application on the page’s origin, or set the service entry points and CORS.

Reproduced

An HTTP 404 on the RPC call, shown as StatusCodeException

The <url-pattern> is not the module directory plus the @RemoteServiceRelativePath value (the module’s rename-to counts).

Compare the pattern with the path in the browser’s network log.

Official guide

NoServiceEntryPointSpecifiedException: Service implementation URL not specified

The service interface has no @RemoteServiceRelativePath and no ServiceDefTarget.setServiceEntryPoint call.

Add the annotation.

Official guide and source

This application is out of date, please click the refresh button on your browser.

An open tab of an old release called a service whose methods or types changed.

Show a reload prompt in onFailure for IncompatibleRemoteServiceException; keep the old policy files for a while.

2.13.1 source

Users get an old application after a release

bookshelf.nocache.js was cached (a long max-age, or a CDN that ignores no-cache).

Revalidate it on every use and purge it on deploy.

Official FAQ

The CDN never hits its cache for the JavaScript

The responses carry Set-Cookie: the Bookshelf’s session filter was mapped to /*.

Map session creation to the dynamic paths only.

Reproduced (first Jetty run)

HttpMessageNotWritableException: No converter for [class java.util.LinkedHashMap] with preset Content-Type 'text/plain;charset=UTF-8' and a 500, in a Spring Boot servlet

The servlet set its content type and then the container threw (a too-large upload), so Spring Boot tried to render its JSON error page into a text/plain response.

Catch the container’s exception in the servlet, as UploadServlet does, and answer with your own status.

Reproduced

IllegalStateException: max file size exceeded: 1048576, wrapped as ServletException ... BadMessageException: 400: bad multipart (Jetty 12.1.10)

A part is over max-file-size; Jetty’s default error page prints a stack trace.

getPart in a try block, a 413 answer of your own, and an error page without traces.

Reproduced

SizeLimitExceededException: the request was rejected because its size (2000347) exceeds the configured maximum (1100000) (Tomcat 11.0.24)

The request is over max-request-size.

As above.

Reproduced

The host page is Spanish for a visitor without Accept-Language

HttpServletRequest.getLocale() returns the server’s default locale when the header is absent.

Test the header yourself and pick a default.

Reproduced

<pre style="word-wrap: break-word; white-space: pre-wrap;">...</pre> instead of the plain text in the form result

FormPanel returns the iframe body’s innerHTML and the browser wraps plain text.

Match with a strict pattern; use setText.

Reproduced

The WAR has twice as many <hash>.cache.js files as permutations

mvn package without clean after the client was recompiled: the overlay keeps the files of earlier builds.

mvn clean package for the artifact that is deployed.

Reproduced

An unexpected </script> or script in the host page

User data was concatenated into the page without escaping (the pattern of the official article’s example).

ScriptJson.quote for JSON in script elements, HTML escaping in attributes.

Test of this page

Recent changes and where the older material is outdated

  • 2.11.0 (2024-01-09): Jakarta artifacts for GWT-RPC and RequestFactory: gwt-servlet-jakarta and the package com.google.gwt.user.server.rpc.jakarta. Applications on Tomcat 10.1 or 11, Jetty 11 or 12 and Spring Boot 3 or later need them; the javax classes still ship for Tomcat 9 and earlier. 2.10.1 and 2.11.0 also stopped serialising JPA and JDO enhanced classes by default, which is why the policy loader above logs a warning or an error for such policies; the GWT-RPC page of this section covers it.

  • 2.12.0: fixes for Content Security Policy in the linkers and development mode, and workarounds for GWT-RPC payloads under a CSP (release notes); the host page data block above does not need an inline script.

  • 2.13.0: the development-mode server defaults to serving only static files, so a real servlet container, as in this page, is what runs the server side in development as well.

  • The official deployment pages still describe the Ant layout (war/WEB-INF/lib/gwt-servlet.jar, build.xml, ant war), webAppCreator, the .cache.html files and App Engine’s appcfg.sh. The principles hold; the commands and file names do not.

  • GWT in Action is from the era of GWT 1.3 and 1.4. Its deployment chapter explains the Tomcat webapps layout, how to move the generated scripts into a directory of their own and how to prune files such as the rebind decision files and the history frame, for a compiler that wrote gwt.js, .cache.html files and the hosted-mode servlet. The compiler of 2.13 writes a single selection script and .cache.js files in a directory, nothing needs pruning, and the output directory is the -war argument. Its upload chapter shows a servlet written with the Apache Commons FileUpload library, which the Servlet 3 Part API made unnecessary; the advice to store and check what is uploaded still applies and is expanded above. Its hosted-mode web.xml listing, with a GWTShellServlet, belongs to a tool that no longer exists.