feat(ext-vite): replace the web bundler with a Vite extension and its Maven plugin

flash-ext-vite runs Vite's dev server in DEV and otherwise serves the build from the
classpath, read straight from the directory or jar with no manifest. The Maven plugin
flash-ext-vite-maven-plugin builds the frontend at prepare-package and packages it
there, so mvn package makes a jar that serves its own frontend and mvn test needs no
Node. Three overrides remain (root, devPort, basePath); the package manager is read
from the nearest lockfile.

Serving fixes what the bundler got wrong: Vite's hashed files under assets/ are
cached as immutable instead of revalidated, HEAD reports the real Content-Length, a
missing asset is a 404 instead of the index, 304s carry ETag and Cache-Control, and
gzip respects q=0 and is prepared at boot. Every response header is pre-encoded, so
serving allocates nothing, which is what Response.type(byte[]) is for. Vite stops
with the app through onClose, and a lockfile change reinstalls before restarting.

The modes, strategies, logging and command-safety options, the asset-source
abstraction, the manifest and the Jackson dependency are gone: 1,535 lines of main
code become 480, plus 84 for the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 15:47:28 +00:00
co-authored by Claude Opus 5
parent 6d44f9e7b1
commit 580417e952
82 changed files with 1150 additions and 2197 deletions
@@ -0,0 +1,56 @@
package dev.relism.flash.ext.vite;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AssetsTest {
@TempDir
Path dir;
/** What production runs from: the build inside a jar, not a directory. */
@Test
void theBuildIsReadFromInsideAJar() throws IOException {
Path jar = dir.resolve("app.jar");
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) {
for (String name : new String[]{"flash-vite/index.html", "flash-vite/assets/a-12345678.css"}) {
out.putNextEntry(new JarEntry(name));
out.write("x".getBytes());
}
}
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());
}
}
@Test
void noBuildFailsTheBootNamingThePlugin() throws IOException {
try (URLClassLoader empty = new URLClassLoader(new URL[0], null)) {
assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/")).getMessage().contains("flash-ext-vite-maven-plugin"));
}
}
@Test
void gzipIsAcceptedUnlessRefused() {
assertTrue(Assets.acceptsGzip("gzip, deflate, br"));
assertTrue(Assets.acceptsGzip("br;q=1.0, gzip;q=0.5"));
assertFalse(Assets.acceptsGzip("gzip;q=0, br"));
assertFalse(Assets.acceptsGzip("gzip;q=0.000"));
assertFalse(Assets.acceptsGzip("br"));
assertFalse(Assets.acceptsGzip(null));
}
}
@@ -0,0 +1,56 @@
package dev.relism.flash.ext.vite;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
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 real child process: a package.json whose dev script stands in for Vite. Needs npm on the PATH. */
class DevServerTest {
@TempDir
Path project;
@Test
void startsTheDevScriptWaitsForItAndTakesItDownOnClose() throws Exception {
assumeTrue(onPath("npm"), "npm is not on the PATH");
int port;
try (ServerSocket free = new ServerSocket(0)) {
port = free.getLocalPort();
}
Files.writeString(project.resolve("package.json"), """
{"name":"t","private":true,"scripts":{"dev":"node -e \\"require('http').createServer((q,s)=>s.end('ok')).listen(+process.argv[process.argv.indexOf('--port')+1],'127.0.0.1')\\" --"}}
""");
DevServer server = new DevServer(project, port);
assertTrue(answers(port));
assertTrue(Files.isRegularFile(project.resolve("node_modules/.flash-vite")));
server.close();
assertFalse(answers(port));
}
private static boolean answers(int port) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
return true;
} catch (IOException e) {
return false;
}
}
private static boolean onPath(String command) {
try {
return new ProcessBuilder(command, "--version").start().waitFor() == 0;
} catch (IOException | InterruptedException e) {
return false;
}
}
}
@@ -0,0 +1,41 @@
package dev.relism.flash.ext.vite;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PackageManagerTest {
@TempDir
Path workspace;
/** A project inside a workspace installs from the workspace's lockfile; its own one wins when it has one. */
@Test
void theNearestLockfileDecides() throws IOException {
Path project = Files.createDirectories(workspace.resolve("apps/web"));
Files.writeString(workspace.resolve("pnpm-lock.yaml"), "");
PackageManager.Found found = PackageManager.of(project);
assertEquals(PackageManager.PNPM, found.manager());
assertTrue(found.install().getFirst().startsWith("pnpm"));
assertEquals(List.of("install", "--frozen-lockfile"), found.install().subList(1, 3));
Files.writeString(project.resolve("package-lock.json"), "");
assertEquals(PackageManager.NPM, PackageManager.of(project).manager());
assertEquals("ci", PackageManager.of(project).install().get(1));
}
@Test
void npmNeedsTheSeparatorBeforeScriptArguments() {
PackageManager.Found npm = new PackageManager.Found(PackageManager.NPM, null);
assertEquals(List.of("run", "dev", "--", "--port", "1"), npm.run("dev", "--port", "1").subList(1, 6));
assertEquals("install", npm.install().get(1));
assertEquals(List.of("run", "dev", "--port", "1"), new PackageManager.Found(PackageManager.PNPM, null).run("dev", "--port", "1").subList(1, 5));
}
}
@@ -0,0 +1,70 @@
package dev.relism.flash.ext.vite;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/** The packaged build (src/test/resources/flash-vite) served by a real server, outside DEV. */
class ViteExtensionTest {
private static final String JS = "/assets/app-AbCd1234.js";
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash.get("/api/before", (req, res) -> "before")
.install(new ViteExtension()).get("/api/ping", (req, res) -> "pong"));
@RegisterExtension
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
void hashedAssetsAreCachedForeverTheRestRevalidates() {
app.get(JS).expectStatus(200)
.expectHeader("Cache-Control", "public, max-age=31536000, immutable")
.expectHeader("Content-Type", "text/javascript; charset=utf-8")
.expectBodyContains("built");
app.get("/favicon.svg").expectStatus(200).expectHeader("Cache-Control", "no-cache");
}
@Test
void gzipIsServedOnlyWhenAccepted() {
app.request().header("Accept-Encoding", "br, gzip").get(JS).expectHeader("Content-Encoding", "gzip").expectHeader("Vary", "Accept-Encoding");
assertNull(app.request().header("Accept-Encoding", "gzip;q=0").get(JS).header("Content-Encoding"));
assertNull(app.get(JS).header("Content-Encoding"));
}
@Test
void clientRoutesGetTheIndexMissingFilesA404() {
app.get("/content/2").expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa");
app.get("/assets/missing.js").expectStatus(404);
}
@Test
void anUnchangedAssetIsNotSentAgain() {
String etag = app.get(JS).header("ETag");
app.request().header("If-None-Match", etag).get(JS).expectStatus(304)
.expectHeader("ETag", etag).expectHeader("Cache-Control", "public, max-age=31536000, immutable");
}
@Test
void headDescribesTheBodyWithoutSendingIt() {
String length = app.get(JS).header("Content-Length");
var head = app.request().head(JS).expectStatus(200).expectHeader("Content-Length", length);
assertEquals("", head.body());
}
@Test
void aBasePathPrefixesEveryRoute() {
nested.get("/app" + JS).expectStatus(200);
nested.get("/app/settings").expectStatus(200).expectBodyContains("spa");
nested.get(JS).expectStatus(404);
}
}
@@ -0,0 +1,100 @@
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"/>

After

Width:  |  Height:  |  Size: 42 B

@@ -0,0 +1 @@
<!doctype html><html><body><div id="root">spa</div></body></html>