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,134 @@
# flash-ext-vite
A Vite frontend for a Flash app, in two artifacts:
- **`flash-ext-vite`**, the extension. In DEV it runs Vite's dev server beside the app. Anywhere
else it serves the built frontend from the classpath as a single-page app.
- **`flash-ext-vite-maven-plugin`**, the build. It builds the frontend during `mvn package` and puts
the result where the extension reads it, so the jar runs on its own.
## Quick start
A Vite project in `web/` with the template's `dev` and `build` scripts, and:
```java
FlashApp.create(8080)
.install(new ViteExtension())
.get("/api/hello", (req, res) -> "hi")
.start();
```
```xml
<plugin>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-vite-maven-plugin</artifactId>
<version>${flash.version}</version>
<executions>
<execution>
<goals><goal>build</goal></goals>
</execution>
</executions>
</plugin>
```
That is the whole setup. `FLASH_ENV=dev` gives you Vite with hot reload, and `mvn package` gives you
a jar that serves the frontend.
## Conventions
These are fixed on purpose: each one is the Vite default, or the thing that keeps the two artifacts
in agreement.
| | |
|---|---|
| Project | a Vite project whose `package.json` has a `dev` and a `build` script |
| Build output | `dist/` inside the project (Vite's `build.outDir` default) |
| Inside the jar | `flash-vite/` (`ViteExtension.CLASSPATH`) |
| Content-hashed files | everything under `assets/` (Vite's `build.assetsDir` default) |
| Package manager | the one whose lockfile is nearest at or above the project: `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json`. With none, npm. A project inside a workspace therefore uses the workspace's lockfile. |
| Node | on the `PATH`, with the package manager. Nothing is downloaded. |
## What you can override
| Extension | Default | |
|---|---|---|
| `root(Path)` | `web` | The Vite project, relative to the working directory. DEV only. |
| `devPort(int)` | `5173` | Vite's port in DEV. |
| `basePath(String)` | `/` | Where the frontend is served. Vite's `base` must match it. |
| Plugin parameter | Default | |
|---|---|---|
| `root` / `-Dflash.vite.root` | `${project.basedir}/web` | The Vite project. |
| `skip` / `-Dflash.vite.skip` | `false` | Leaves the frontend out, for a backend-only build. |
There is nothing else to set. If a convention above does not fit, that is a change to this module,
not a configuration option.
## DEV
`Flash.DEV` (`FLASH_ENV=dev` or `-Dflash.env=dev`) decides the mode:
1. The dependencies are installed with the lockfile pinned (`pnpm install --frozen-lockfile`,
`npm ci`, and so on). This is skipped when `node_modules` was already installed from that same
lockfile: its hash is kept in `node_modules/.flash-vite`, so deleting `node_modules` resets it.
2. The extension runs `<pm> run dev --host 127.0.0.1 --port <devPort> --strictPort` and waits up to
30 s for the port to answer. A port already in use, a script that exits, or a timeout fails the
boot with the reason. Vite's output is logged at INFO, prefixed `[vite]`.
3. While the app runs, a change to the lockfile (`pnpm add …`) reinstalls and restarts Vite. Vite
handles changes to its own config itself.
4. When the app stops (`ctx.onClose`), Vite and its whole process tree stop with it.
The extension registers no routes in DEV. The browser opens Vite's port, and Vite forwards the
backend's paths to Flash, so `vite.config` needs a `server.proxy` for them:
```ts
server: {
proxy: { '^/(api|auth)(/|$)': 'http://localhost:8080' },
},
```
## Production
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
the plugin. 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
prefers specific routes over the wildcard.
| Request | Answer |
|---|---|
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable` |
| 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 |
| a path with an extension that matches no file | `404` |
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, 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
and 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.
Known limits:
- A client-side route whose last segment has a dot (`/users/ada.lovelace`) is treated as a missing
file. Keep dots out of client routes, or route them under `basePath` differently.
- 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.
## The plugin
The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node, and `mvn package`,
`verify` and `install` always produce a jar with its frontend. It:
1. installs the dependencies with the lockfile pinned;
2. runs `<pm> run build` in the project;
3. replaces `target/classes/flash-vite/` with the project's `dist/`.
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
the Maven log.
A Docker image therefore needs one build stage with both a JDK and Node. The frontend does not need
its own stage.
+34
View File
@@ -0,0 +1,34 @@
<?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</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,182 @@
package dev.relism.flash.ext.vite;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.PreEncodedHeader;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemAlreadyExistsException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import java.util.Set;
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
* response carries, headers included, is prepared here, so serving allocates nothing.
*/
final class Assets {
private static final PreEncodedHeader IMMUTABLE = new PreEncodedHeader("Cache-Control", "public, max-age=31536000, immutable");
private static final PreEncodedHeader REVALIDATE = new PreEncodedHeader("Cache-Control", "no-cache");
private static final PreEncodedHeader VARY = new PreEncodedHeader("Vary", "Accept-Encoding");
private static final PreEncodedHeader GZIP = new PreEncodedHeader("Content-Encoding", "gzip");
private static final Map<String, String> TYPES = Map.ofEntries(
Map.entry("html", "text/html; charset=utf-8"),
Map.entry("js", "text/javascript; charset=utf-8"),
Map.entry("mjs", "text/javascript; charset=utf-8"),
Map.entry("css", "text/css; charset=utf-8"),
Map.entry("json", "application/json"),
Map.entry("map", "application/json"),
Map.entry("webmanifest", "application/manifest+json"),
Map.entry("txt", "text/plain; charset=utf-8"),
Map.entry("xml", "application/xml"),
Map.entry("svg", "image/svg+xml"),
Map.entry("png", "image/png"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"),
Map.entry("webp", "image/webp"),
Map.entry("avif", "image/avif"),
Map.entry("ico", "image/x-icon"),
Map.entry("woff", "font/woff"),
Map.entry("woff2", "font/woff2"),
Map.entry("ttf", "font/ttf"),
Map.entry("otf", "font/otf"),
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) {}
final Map<String, Asset> byPath;
private final Asset index;
/** Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under {@code basePath}. */
Assets(ClassLoader loader, String basePath) {
URL index = loader.getResource(ViteExtension.CLASSPATH + "/index.html");
if (index == null) {
throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): "
+ "package the application with flash-ext-vite-maven-plugin, or run it with FLASH_ENV=dev.");
}
String prefix = "/".equals(basePath) ? "" : basePath;
Map<String, Asset> assets = new HashMap<>();
try {
URI uri = index.toURI();
FileSystem opened = null;
if ("jar".equals(uri.getScheme())) {
try {
opened = FileSystems.newFileSystem(uri, Map.of());
} catch (FileSystemAlreadyExistsException alreadyOpen) {
// Path.of below resolves against the one already open.
}
}
Path root = Path.of(uri).getParent();
try (Stream<Path> files = Files.walk(root)) {
for (Path file : files.filter(Files::isRegularFile).toList()) {
String relative = root.relativize(file).toString().replace('\\', '/');
assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file)));
}
} finally {
if (opened != null) opened.close();
}
} catch (IOException | URISyntaxException e) {
throw new IllegalStateException("Cannot read the built frontend at " + index, e);
}
this.byPath = Map.copyOf(assets);
this.index = byPath.get(prefix + "/index.html");
}
/**
* The asset at the request's path; a path with no file extension is a client-side route and
* gets {@code index.html}, one with an extension that matches nothing is a 404.
*/
void serve(Request req, Response res) {
String path = req.path();
Asset asset = byPath.get(path);
if (asset == null) {
if (path.lastIndexOf('.') > path.lastIndexOf('/')) {
res.status(HttpStatus.NOT_FOUND);
return;
}
asset = index;
}
res.header(asset.cache).header(asset.etag);
String known = req.header("If-None-Match");
if (known != null && known.contains(asset.tag)) {
res.status(HttpStatus.NOT_MODIFIED);
return;
}
res.type(asset.type);
if (asset.gzip != null) {
res.header(VARY);
if (acceptsGzip(req.header("Accept-Encoding"))) {
res.header(GZIP).body(asset.gzip);
return;
}
}
res.body(asset.raw);
}
/** Listed in {@code Accept-Encoding}, and not with a q of zero. */
static boolean acceptsGzip(String accept) {
if (accept == null) return false;
int at = accept.indexOf("gzip");
if (at < 0) return false;
int end = accept.indexOf(',', at);
if (end < 0) end = accept.length();
int q = accept.indexOf("q=", at);
if (q < 0 || q > end) return true;
for (int i = q + 2; i < end; i++) {
char c = accept.charAt(i);
if (c >= '1' && c <= '9') return true;
if (c != '0' && c != '.') break;
}
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) {
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
byte[] type = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII);
byte[] gzip = COMPRESSIBLE.contains(extension) && raw.length >= 1024 ? gzip(raw) : null;
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
return new Asset(raw, gzip != null && gzip.length < raw.length ? gzip : null, type,
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) {
try {
return MessageDigest.getInstance("SHA-1").digest(raw);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,173 @@
package dev.relism.flash.ext.vite;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Vite's dev server as a child process of the app: dependencies installed whenever the lockfile
* differs from the one they were installed from, the server restarted when it changes under it,
* and the whole process tree gone when the app stops.
*/
final class DevServer implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(DevServer.class);
private final Path root;
private final int port;
private final PackageManager.Found packages;
private final ScheduledExecutorService watch = Executors.newSingleThreadScheduledExecutor(
Thread.ofPlatform().daemon().name("flash-vite-watch").factory());
private Process process;
private FileTime lockfileTime;
DevServer(Path root, int port) {
if (!Files.isRegularFile(root.resolve("package.json"))) {
throw new IllegalStateException("No package.json in " + root.toAbsolutePath() + ": point ViteExtension.root(...) at the Vite project.");
}
this.root = root;
this.port = port;
this.packages = PackageManager.of(root);
lockfileTime = modified();
try {
install();
start();
} catch (RuntimeException failed) {
close();
throw failed;
}
if (packages.lockfile() != null) watch.scheduleWithFixedDelay(this::restartIfLockfileChanged, 2, 2, TimeUnit.SECONDS);
}
@Override
public synchronized void close() {
watch.shutdownNow();
stop();
}
private synchronized void restartIfLockfileChanged() {
FileTime now = modified();
if (now.equals(lockfileTime)) return;
lockfileTime = now;
log.info("{} changed, reinstalling and restarting Vite", packages.lockfile().getFileName());
try {
stop();
install();
start();
} catch (RuntimeException failed) {
log.error("Vite did not come back; fix the cause and touch the lockfile to retry", failed);
}
}
/** Skipped when {@code node_modules} was installed from this very lockfile. */
private void install() {
Path stamp = root.resolve("node_modules/.flash-vite");
String lockfile = packages.lockfile() == null ? "" : sha256(packages.lockfile());
try {
if (Files.isRegularFile(stamp) && Files.readString(stamp).equals(lockfile)) return;
Process install = spawn(packages.install());
if (install.waitFor() != 0) throw new IllegalStateException(String.join(" ", packages.install()) + " failed with " + install.exitValue());
Files.createDirectories(stamp.getParent());
Files.writeString(stamp, lockfile);
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted installing the frontend's dependencies", e);
}
}
private void start() {
if (answers()) throw new IllegalStateException("Port " + port + " is taken: stop what uses it or set ViteExtension.devPort(...).");
process = spawn(packages.run("dev", "--host", "127.0.0.1", "--port", String.valueOf(port), "--strictPort"));
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
while (!answers()) {
if (!process.isAlive()) throw new IllegalStateException("Vite exited with " + process.exitValue() + " before serving on port " + port + " (its output is logged above).");
if (System.nanoTime() > deadline) throw new IllegalStateException("Vite did not answer on port " + port + " within 30 seconds.");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted waiting for Vite", e);
}
}
log.info("Vite is serving http://127.0.0.1:{}", port);
}
/** npm, pnpm and friends are wrappers: their children have to go as well, gracefully first. */
private void stop() {
if (process == null) return;
List<ProcessHandle> tree = process.descendants().toList();
tree.forEach(ProcessHandle::destroy);
process.destroy();
try {
process.onExit().get(5, TimeUnit.SECONDS);
} catch (Exception gone) {
// Forced below.
}
tree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
if (process.isAlive()) process.destroyForcibly();
process = null;
}
/** Starts {@code command} in the project, its output logged line by line. */
private Process spawn(List<String> command) {
Process started;
try {
started = new ProcessBuilder(command).directory(root.toFile()).redirectErrorStream(true).start();
} catch (IOException e) {
throw new IllegalStateException("Cannot run " + command.getFirst() + ": is it installed and on the PATH?", e);
}
Thread.ofVirtual().start(() -> {
try (BufferedReader out = new BufferedReader(new InputStreamReader(started.getInputStream(), StandardCharsets.UTF_8))) {
for (String line; (line = out.readLine()) != null; ) log.info("[vite] {}", line);
} catch (IOException closed) {
// The process ended.
}
});
return started;
}
private boolean answers() {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
return true;
} catch (IOException e) {
return false;
}
}
private FileTime modified() {
try {
return packages.lockfile() == null ? FileTime.fromMillis(0) : Files.getLastModifiedTime(packages.lockfile());
} catch (IOException e) {
return FileTime.fromMillis(0);
}
}
private static String sha256(Path file) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)));
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,63 @@
package dev.relism.flash.ext.vite;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** The package manager a frontend uses, told apart by its lockfile. Shared with the Maven plugin. */
public enum PackageManager {
PNPM("pnpm-lock.yaml", "install", "--frozen-lockfile"),
YARN("yarn.lock", "install", "--frozen-lockfile"),
BUN("bun.lock", "install", "--frozen-lockfile"),
NPM("package-lock.json", "ci");
private static final boolean WINDOWS = System.getProperty("os.name", "").startsWith("Windows");
private final String lockfile;
private final String[] install;
PackageManager(String lockfile, String... install) {
this.lockfile = lockfile;
this.install = install;
}
/** A package manager and the lockfile it was found by, {@code null} when there is none. */
public record Found(PackageManager manager, Path lockfile) {
/** Installs exactly what the lockfile pins; without one, npm resolves afresh. */
public List<String> install() {
if (lockfile == null) return List.of(manager.binary(), "install");
List<String> command = new ArrayList<>(List.of(manager.binary()));
command.addAll(List.of(manager.install));
return command;
}
/** Runs a {@code package.json} script, passing {@code args} through to it. */
public List<String> run(String script, String... args) {
List<String> command = new ArrayList<>(List.of(manager.binary(), "run", script));
if (manager == NPM && args.length > 0) command.add("--");
command.addAll(List.of(args));
return command;
}
}
/**
* The nearest lockfile at or above {@code root}, so a project inside a workspace finds the
* workspace's. None at all is npm without a lockfile.
*/
public static Found of(Path root) {
for (Path dir = root.toAbsolutePath().normalize(); dir != null; dir = dir.getParent()) {
for (PackageManager manager : values()) {
Path lockfile = dir.resolve(manager.lockfile);
if (Files.isRegularFile(lockfile)) return new Found(manager, lockfile);
}
}
return new Found(NPM, null);
}
private String binary() {
String name = name().toLowerCase();
return WINDOWS ? name + ".cmd" : name;
}
}
@@ -0,0 +1,62 @@
package dev.relism.flash.ext.vite;
import dev.relism.flash.Flash;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import java.nio.file.Path;
/**
* A Vite frontend for a Flash app. In DEV it runs Vite's dev server beside the app, which the
* browser talks to and which proxies the backend's paths to Flash. Otherwise it serves the build
* that {@code flash-ext-vite-maven-plugin} packaged into the jar, as a single-page app.
*/
public final class ViteExtension implements FlashExtension {
/** Where the build lives on the classpath: the Maven plugin puts it there, production reads it from there. */
public static final String CLASSPATH = "flash-vite";
private Path root = Path.of("web");
private int devPort = 5173;
private String basePath = "/";
/** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */
public ViteExtension root(Path root) {
this.root = root;
return this;
}
/** Vite's port in DEV, 5173 by default. */
public ViteExtension devPort(int devPort) {
this.devPort = devPort;
return this;
}
/** Where the frontend is served from, {@code /} by default; Vite's {@code base} has to match it. */
public ViteExtension basePath(String basePath) {
String trimmed = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
this.basePath = trimmed.isEmpty() ? "/" : trimmed.startsWith("/") ? trimmed : "/" + trimmed;
return this;
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
if (Flash.DEV) {
ctx.onClose(new DevServer(root, devPort)::close);
return;
}
Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath);
String everything = "/".equals(basePath) ? "/**" : basePath + "/**";
ctx.onReady(() -> {
app.get(everything, (req, res) -> {
assets.serve(req, res);
return null;
});
app.head(everything, (req, res) -> {
assets.serve(req, res);
return 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.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>