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,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-vite-maven-plugin</artifactId>
<packaging>maven-plugin</packaging>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-vite</artifactId>
</dependency>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId>
<version>3.9.9</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven.plugin-tools</groupId>
<artifactId>maven-plugin-annotations</artifactId>
<version>3.15.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-plugin-plugin</artifactId>
<version>3.15.1</version>
<configuration>
<goalPrefix>flash-vite</goalPrefix>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,84 @@
package dev.relism.flash.ext.vite.maven;
import dev.relism.flash.ext.vite.PackageManager;
import dev.relism.flash.ext.vite.ViteExtension;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
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.stream.Stream;
/**
* 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.
*/
@Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true)
public final class BuildMojo extends AbstractMojo {
/** The Vite project. */
@Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
File root;
@Parameter(defaultValue = "${project.build.outputDirectory}", readonly = true, required = true)
File classes;
/** Leaves the frontend out, for a build that only needs the backend. */
@Parameter(property = "flash.vite.skip", defaultValue = "false")
boolean skip;
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping the frontend build");
return;
}
Path project = root.toPath();
if (!Files.isRegularFile(project.resolve("package.json"))) {
throw new MojoExecutionException("No package.json in " + project + ": set <root> to the Vite project.");
}
PackageManager.Found packages = PackageManager.of(project);
run(project, packages.install());
run(project, packages.run("build"));
Path dist = project.resolve("dist");
if (!Files.isRegularFile(dist.resolve("index.html"))) {
throw new MojoExecutionException("The build left no dist/index.html in " + project + ".");
}
Path target = classes.toPath().resolve(ViteExtension.CLASSPATH);
try {
if (Files.exists(target)) {
try (Stream<Path> old = Files.walk(target)) {
for (Path path : old.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
}
}
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()));
}
} catch (IOException e) {
throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e);
}
getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/");
}
private static void run(Path project, List<String> command) throws MojoExecutionException {
int exit;
try {
exit = new ProcessBuilder(command).directory(project.toFile()).inheritIO().start().waitFor();
} catch (IOException e) {
throw new MojoExecutionException("Cannot run " + command.getFirst() + ": is Node installed and " + command.getFirst() + " on the PATH?", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new MojoExecutionException("Interrupted running " + String.join(" ", command), e);
}
if (exit != 0) throw new MojoExecutionException(String.join(" ", command) + " failed with " + exit + " (its output is above).");
}
}
@@ -0,0 +1,59 @@
package dev.relism.flash.ext.vite.maven;
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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */
class BuildMojoTest {
@TempDir
Path dir;
@Test
void theBuildLandsWhereTheExtensionServesItFrom() throws Exception {
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')\\""}}
""");
Path classes = dir.resolve("classes");
Files.createDirectories(classes.resolve("flash-vite"));
Files.writeString(classes.resolve("flash-vite/stale.js"), "from the last build");
mojo(web, classes, false).execute();
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")));
}
@Test
void skipBuildsNothing() throws Exception {
mojo(dir.resolve("nowhere"), dir.resolve("classes"), true).execute();
assertFalse(Files.exists(dir.resolve("classes")));
}
private static BuildMojo mojo(Path root, Path classes, boolean skip) {
BuildMojo mojo = new BuildMojo();
mojo.root = root.toFile();
mojo.classes = classes.toFile();
mojo.skip = skip;
return mojo;
}
private static boolean onPath() {
try {
return new ProcessBuilder("npm", "--version").start().waitFor() == 0;
} catch (IOException | InterruptedException e) {
return false;
}
}
}