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,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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
+62
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+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