feat(ext-vite): gzip at build time, fall back to the index only for navigations

The Maven plugin now writes a maximally compressed .gz beside every text file of 1 KB
and more, so the server compresses nothing and boot only reads the files: about 50 ms
for Glossa's 500. Hashed files under assets/ skip the ETag, which nothing ever asks for.

A path that is no file gets index.html only when the request's Accept names text/html,
as a browser navigation does. Everything else, an API call to a missing route included,
gets the app's own 404 instead of the index, and a dot in a client route no longer
matters. The base path itself always serves the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 15:54:19 +00:00
co-authored by Claude Opus 5
parent 580417e952
commit 680bbca8c7
7 changed files with 100 additions and 70 deletions
@@ -8,22 +8,30 @@ import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import java.util.zip.Deflater;
import java.util.zip.GZIPOutputStream;
/**
* Builds the Vite project and packages the result where {@code ViteExtension} serves it from in
* production. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has
* its frontend.
* production, with a maximally gzipped {@code .gz} beside each text file, so the server compresses
* nothing. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has its
* frontend.
*/
@Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true)
public final class BuildMojo extends AbstractMojo {
/** Formats that are not compressed already; images and woff gain nothing from gzip. */
private static final Set<String> COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm");
/** The Vite project. */
@Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
File root;
@@ -61,7 +69,11 @@ public final class BuildMojo extends AbstractMojo {
}
Files.createDirectories(target.getParent());
try (Stream<Path> built = Files.walk(dist)) {
for (Path from : built.toList()) Files.copy(from, target.resolve(dist.relativize(from).toString()));
for (Path from : built.toList()) {
Path to = target.resolve(dist.relativize(from).toString());
Files.copy(from, to);
if (Files.isRegularFile(to)) gzip(to);
}
}
} catch (IOException e) {
throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e);
@@ -69,6 +81,18 @@ public final class BuildMojo extends AbstractMojo {
getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/");
}
/** Only worth it from 1 KB, and only kept when it is smaller. */
private static void gzip(Path file) throws IOException {
String name = file.getFileName().toString();
if (!COMPRESSIBLE.contains(name.substring(name.lastIndexOf('.') + 1).toLowerCase()) || Files.size(file) < 1024) return;
byte[] raw = Files.readAllBytes(file);
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2);
try (GZIPOutputStream zip = new GZIPOutputStream(out) {{ def.setLevel(Deflater.BEST_COMPRESSION); }}) {
zip.write(raw);
}
if (out.size() < raw.length) Files.write(file.resolveSibling(name + ".gz"), out.toByteArray());
}
private static void run(Path project, List<String> command) throws MojoExecutionException {
int exit;
try {
@@ -9,6 +9,7 @@ import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */
@@ -22,7 +23,7 @@ class BuildMojoTest {
assumeTrue(onPath(), "npm is not on the PATH");
Path web = Files.createDirectories(dir.resolve("web"));
Files.writeString(web.resolve("package.json"), """
{"name":"t","private":true,"scripts":{"build":"node -e \\"const f=require('fs');f.mkdirSync('dist/assets',{recursive:true});f.writeFileSync('dist/index.html','built');f.writeFileSync('dist/assets/a-12345678.js','1')\\""}}
{"name":"t","private":true,"scripts":{"build":"node -e \\"const f=require('fs');f.mkdirSync('dist/assets',{recursive:true});f.writeFileSync('dist/index.html','built');f.writeFileSync('dist/assets/a-12345678.js','1');f.writeFileSync('dist/assets/b-12345678.js','x'.repeat(4096))\\""}}
""");
Path classes = dir.resolve("classes");
Files.createDirectories(classes.resolve("flash-vite"));
@@ -33,6 +34,9 @@ class BuildMojoTest {
assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html")));
assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js")));
assertFalse(Files.exists(classes.resolve("flash-vite/stale.js")));
// Big enough to gzip, and it shrinks; the 1-byte file is left alone.
assertTrue(Files.size(classes.resolve("flash-vite/assets/b-12345678.js.gz")) < 4096);
assertFalse(Files.exists(classes.resolve("flash-vite/assets/a-12345678.js.gz")));
}
@Test