feat: Vite extension and Maven plugin; data pools close on stop #19

Merged
Relism merged 5 commits from feature/ext-vite/replace-web-bundler into master 2026-09-22 16:27:47 +00:00
7 changed files with 100 additions and 70 deletions
Showing only changes of commit 680bbca8c7 - Show all commits
@@ -8,22 +8,30 @@ import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.Parameter;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Stream; 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 * 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 * production, with a maximally gzipped {@code .gz} beside each text file, so the server compresses
* its frontend. * 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) @Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true)
public final class BuildMojo extends AbstractMojo { 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. */ /** The Vite project. */
@Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web") @Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
File root; File root;
@@ -61,7 +69,11 @@ public final class BuildMojo extends AbstractMojo {
} }
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
try (Stream<Path> built = Files.walk(dist)) { 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) { } catch (IOException e) {
throw new MojoExecutionException("Cannot copy " + dist + " to " + target, 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 + "/"); 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 { private static void run(Path project, List<String> command) throws MojoExecutionException {
int exit; int exit;
try { 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue;
/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */ /** 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"); assumeTrue(onPath(), "npm is not on the PATH");
Path web = Files.createDirectories(dir.resolve("web")); Path web = Files.createDirectories(dir.resolve("web"));
Files.writeString(web.resolve("package.json"), """ 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"); Path classes = dir.resolve("classes");
Files.createDirectories(classes.resolve("flash-vite")); Files.createDirectories(classes.resolve("flash-vite"));
@@ -33,6 +34,9 @@ class BuildMojoTest {
assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html"))); assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html")));
assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js"))); assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js")));
assertFalse(Files.exists(classes.resolve("flash-vite/stale.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 @Test
+16 -11
View File
@@ -91,29 +91,31 @@ server: {
Anywhere `Flash.DEV` is false, the build is read once at boot from `flash-vite/` on the classpath, Anywhere `Flash.DEV` is false, the build is read once at boot from `flash-vite/` on the classpath,
whether that is a directory (`target/classes`) or a jar. A missing build fails the boot and names whether that is a directory (`target/classes`) or a jar. A missing build fails the boot and names
the plugin. Everything a response carries is prepared at boot, headers included, so serving the plugin. Compression already happened in the build, so boot only reads files and hashes the few
allocates nothing. that revalidate: about 50 ms for a 500-file app. Everything a response carries is prepared at
boot, headers included, so serving allocates nothing.
`GET` and `HEAD` on `basePath/**` answer as follows. Backend routes still win, because Flash `GET` and `HEAD` on `basePath/**` answer as follows. Backend routes still win, because Flash
prefers specific routes over the wildcard. prefers specific routes over the wildcard.
| Request | Answer | | Request | Answer |
|---|---| |---|---|
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable` | | `basePath` itself (`/`) | `index.html`, whatever the client accepts |
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable`, no `ETag` (it is never asked for again) |
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` | | any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
| a path with no file extension (`/content/2`) | `index.html`, so the client-side router takes it | | no such file, and `Accept` names `text/html` (a browser navigating to `/content/2`) | `index.html`, so the client-side router takes it |
| a path with an extension that matches no file | `404` | | no such file otherwise (a `fetch` to `/api/typo`, a missing script) | the app's own `404` |
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body | | `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body |
| `HEAD` | the `GET` headers, `Content-Length` included, no body | | `HEAD` | the `GET` headers, `Content-Length` included, no body |
Text formats of 1 KB and more (html, js, css, json, svg, fonts, wasm, …) are gzipped once at boot A `.gz` beside a file is its gzipped form: the plugin writes one for every text file of 1 KB and
and sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with more, and it is sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
`Vary: Accept-Encoding`. Images and woff are sent as they are, because they are compressed already. `Vary: Accept-Encoding`.
Known limits: Known limits:
- A client-side route whose last segment has a dot (`/users/ada.lovelace`) is treated as a missing - A browser navigating straight to an API path that does not exist gets the app, which shows its
file. Keep dots out of client routes, or route them under `basePath` differently. own not-found screen. Only an HTML navigation falls back, so API clients always get the 404.
- Every file is held in memory, gzip included. That suits an app's own frontend. Large media belongs - Every file is held in memory, gzip included. That suits an app's own frontend. Large media belongs
on a CDN or behind its own route. on a CDN or behind its own route.
@@ -124,7 +126,10 @@ The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node,
1. installs the dependencies with the lockfile pinned; 1. installs the dependencies with the lockfile pinned;
2. runs `<pm> run build` in the project; 2. runs `<pm> run build` in the project;
3. replaces `target/classes/flash-vite/` with the project's `dist/`. 3. replaces `target/classes/flash-vite/` with the project's `dist/`;
4. writes a gzip `.gz` at maximum compression beside every html, js, css, json, map, svg, txt,
xml, webmanifest, font and wasm file of 1 KB and more, and keeps it only when it is smaller.
Images and woff are left alone, because they are compressed already.
Missing Node or package manager, a failing install or build, or a build that leaves no Missing Node or package manager, a failing install or build, or a build that leaves no
`dist/index.html` fails the Maven build with the reason. The package manager's own output shows in `dist/index.html` fails the Maven build with the reason. The package manager's own output shows in
@@ -1,13 +1,12 @@
package dev.relism.flash.ext.vite; package dev.relism.flash.ext.vite;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.HttpStatus; import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.PreEncodedHeader; import dev.relism.flash.models.PreEncodedHeader;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
@@ -22,13 +21,12 @@ import java.security.NoSuchAlgorithmException;
import java.util.HashMap; import java.util.HashMap;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.zip.GZIPOutputStream;
/** /**
* The built frontend, read once from the classpath at boot and served from memory: every byte a * The built frontend, read once from the classpath at boot and served from memory: every byte a
* response carries, headers included, is prepared here, so serving allocates nothing. * response carries, headers included, is prepared here, so serving allocates nothing. Compression
* happened at build time: a {@code .gz} beside a file is its gzipped form.
*/ */
final class Assets { final class Assets {
@@ -61,9 +59,6 @@ final class Assets {
Map.entry("otf", "font/otf"), Map.entry("otf", "font/otf"),
Map.entry("wasm", "application/wasm")); Map.entry("wasm", "application/wasm"));
/** Formats that are not compressed already; the rest (images, 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");
record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {} record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {}
final Map<String, Asset> byPath; final Map<String, Asset> byPath;
@@ -92,7 +87,10 @@ final class Assets {
try (Stream<Path> files = Files.walk(root)) { try (Stream<Path> files = Files.walk(root)) {
for (Path file : files.filter(Files::isRegularFile).toList()) { for (Path file : files.filter(Files::isRegularFile).toList()) {
String relative = root.relativize(file).toString().replace('\\', '/'); String relative = root.relativize(file).toString().replace('\\', '/');
assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file))); if (relative.endsWith(".gz")) continue;
Path gzip = file.resolveSibling(file.getFileName() + ".gz");
assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file),
Files.isRegularFile(gzip) ? Files.readAllBytes(gzip) : null));
} }
} finally { } finally {
if (opened != null) opened.close(); if (opened != null) opened.close();
@@ -100,29 +98,34 @@ final class Assets {
} catch (IOException | URISyntaxException e) { } catch (IOException | URISyntaxException e) {
throw new IllegalStateException("Cannot read the built frontend at " + index, e); throw new IllegalStateException("Cannot read the built frontend at " + index, e);
} }
// The root is the app itself, whatever the client accepts.
Asset root = assets.get(prefix + "/index.html");
assets.put(prefix + "/", root);
if (!prefix.isEmpty()) assets.put(prefix, root);
this.byPath = Map.copyOf(assets); this.byPath = Map.copyOf(assets);
this.index = byPath.get(prefix + "/index.html"); this.index = root;
} }
/** /**
* The asset at the request's path; a path with no file extension is a client-side route and * The asset at the request's path. A path that is none is a client-side route when a browser
* gets {@code index.html}, one with an extension that matches nothing is a 404. * navigates to it ({@code Accept} names HTML), so it gets {@code index.html}; for anything
* else, an API call or a missing script, it is the app's own 404.
*/ */
void serve(Request req, Response res) { void serve(Request req, Response res) {
String path = req.path(); Asset asset = byPath.get(req.path());
Asset asset = byPath.get(path);
if (asset == null) { if (asset == null) {
if (path.lastIndexOf('.') > path.lastIndexOf('/')) { String accept = req.header("Accept");
res.status(HttpStatus.NOT_FOUND); if (accept == null || !accept.contains("text/html")) throw HttpException.notFound(req.path());
return;
}
asset = index; asset = index;
} }
res.header(asset.cache).header(asset.etag); res.header(asset.cache);
String known = req.header("If-None-Match"); if (asset.etag != null) {
if (known != null && known.contains(asset.tag)) { res.header(asset.etag);
res.status(HttpStatus.NOT_MODIFIED); String known = req.header("If-None-Match");
return; if (known != null && known.contains(asset.tag)) {
res.status(HttpStatus.NOT_MODIFIED);
return;
}
} }
res.type(asset.type); res.type(asset.type);
if (asset.gzip != null) { if (asset.gzip != null) {
@@ -152,24 +155,16 @@ final class Assets {
return false; return false;
} }
/** Vite puts every content-hashed file under {@code assets/}: those never change, the rest revalidate. */ /**
private static Asset asset(String path, byte[] raw) { * Vite puts every content-hashed file under {@code assets/}: those never change, so they are
* cached for good and never revalidated, and need no ETag. The rest revalidate by one.
*/
private static Asset asset(String path, byte[] raw, byte[] gzip) {
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase(); String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
byte[] type = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII); byte[] type = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII);
byte[] gzip = COMPRESSIBLE.contains(extension) && raw.length >= 1024 ? gzip(raw) : null; if (path.startsWith("assets/")) return new Asset(raw, gzip, type, IMMUTABLE, null, null);
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\""; String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
return new Asset(raw, gzip != null && gzip.length < raw.length ? gzip : null, type, return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), tag);
path.startsWith("assets/") ? IMMUTABLE : REVALIDATE, new PreEncodedHeader("ETag", tag), tag);
}
private static byte[] gzip(byte[] raw) {
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2);
try (GZIPOutputStream zip = new GZIPOutputStream(out)) {
zip.write(raw);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return out.toByteArray();
} }
private static byte[] sha1(byte[] raw) { private static byte[] sha1(byte[] raw) {
@@ -33,7 +33,7 @@ class AssetsTest {
} }
} }
try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) {
assertEquals(Set.of("/index.html", "/assets/a-12345678.css"), new Assets(loader, "/").byPath.keySet()); assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/").byPath.keySet());
} }
} }
@@ -11,20 +11,14 @@ import static org.junit.jupiter.api.Assertions.assertNull;
class ViteExtensionTest { class ViteExtensionTest {
private static final String JS = "/assets/app-AbCd1234.js"; private static final String JS = "/assets/app-AbCd1234.js";
private static final String NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
@RegisterExtension @RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash.get("/api/before", (req, res) -> "before") static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension()));
.install(new ViteExtension()).get("/api/ping", (req, res) -> "pong"));
@RegisterExtension @RegisterExtension
static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/"))); static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/")));
@Test
void backendRoutesWin() {
app.get("/api/ping").expectStatus(200).expectBody("pong");
app.get("/api/before").expectStatus(200).expectBody("before");
}
@Test @Test
void hashedAssetsAreCachedForeverTheRestRevalidates() { void hashedAssetsAreCachedForeverTheRestRevalidates() {
app.get(JS).expectStatus(200) app.get(JS).expectStatus(200)
@@ -41,17 +35,25 @@ class ViteExtensionTest {
assertNull(app.get(JS).header("Content-Encoding")); assertNull(app.get(JS).header("Content-Encoding"));
} }
/** A browser navigating gets the app, dots and all; a fetch or a script tag for nothing gets a 404. */
@Test @Test
void clientRoutesGetTheIndexMissingFilesA404() { void onlyNavigationsFallBackToTheIndex() {
app.get("/content/2").expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa"); for (String route : new String[]{"/content/2", "/users/ada.lovelace"}) {
app.get("/assets/missing.js").expectStatus(404); app.request().header("Accept", NAVIGATION).get(route).expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa");
}
app.request().header("Accept", "application/json").get("/api/nope").expectStatus(404);
app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404);
app.get("/content/2").expectStatus(404);
app.get("/").expectStatus(200).expectBodyContains("spa");
nested.get("/app").expectStatus(200).expectBodyContains("spa");
} }
@Test @Test
void anUnchangedAssetIsNotSentAgain() { void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() {
String etag = app.get(JS).header("ETag"); String etag = app.get("/favicon.svg").header("ETag");
app.request().header("If-None-Match", etag).get(JS).expectStatus(304) app.request().header("If-None-Match", etag).get("/favicon.svg").expectStatus(304)
.expectHeader("ETag", etag).expectHeader("Cache-Control", "public, max-age=31536000, immutable"); .expectHeader("ETag", etag).expectHeader("Cache-Control", "no-cache");
assertNull(app.get(JS).header("ETag"));
} }
@Test @Test
@@ -64,7 +66,7 @@ class ViteExtensionTest {
@Test @Test
void aBasePathPrefixesEveryRoute() { void aBasePathPrefixesEveryRoute() {
nested.get("/app" + JS).expectStatus(200); nested.get("/app" + JS).expectStatus(200);
nested.get("/app/settings").expectStatus(200).expectBodyContains("spa"); nested.request().header("Accept", NAVIGATION).get("/app/settings").expectStatus(200).expectBodyContains("spa");
nested.get(JS).expectStatus(404); nested.get(JS).expectStatus(404);
} }
} }