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:
co-authored by
Claude Opus 5
parent
6d44f9e7b1
commit
580417e952
@@ -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>
|
||||
+84
@@ -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).");
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user