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
+56
@@ -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));
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+70
@@ -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>
|
||||
Reference in New Issue
Block a user