feat: Vite extension and Maven plugin; data pools close on stop #19

Merged
Relism merged 5 commits from feature/ext-vite/replace-web-bundler into master 2026-09-22 16:27:47 +00:00
6 changed files with 116 additions and 29 deletions
Showing only changes of commit 003fd6d1f0 - Show all commits
@@ -55,6 +55,7 @@ in agreement.
| `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. |
| `navigationOnly(boolean)` | `true` | Only browser navigations fall back to `index.html` (see below). `false` gives every `GET` that matches no file the page, as nginx's `try_files` does, which also turns API 404s into the page. Rarely wanted. |
| Plugin parameter | Default | |
|---|---|---|
@@ -103,11 +104,18 @@ prefers specific routes over the wildcard.
| `basePath` itself (`/`) | `index.html`, whatever the client accepts |
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable`, no `ETag` (it is never asked for again) |
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
| no such file, and `Accept` names `text/html` (a browser navigating to `/content/2`) | `index.html`, so the client-side router takes it |
| no such file, and the request is a navigation | `index.html`, so the client-side router takes it |
| no such file otherwise (a `fetch` to `/api/typo`, a missing script) | the app's own `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 |
A navigation is what a browser sends when a person opens a URL: `Sec-Fetch-Mode: navigate`, which
every current browser sets for exactly this purpose, or an `Accept` that names `text/html`, which
covers older browsers, crawlers and `curl -H 'Accept: text/html'`. The check reads the header bytes
in place and allocates nothing. This is stricter than Vite's own dev-server fallback, which also
takes `Accept: */*` and so gives `fetch('/api/typo')` the page. It is the same rule service
workers use for their navigation fallback. With `navigationOnly(false)` the check is skipped.
A `.gz` beside a file is its gzipped form: the plugin writes one for every text file of 1 KB and
more, and it is sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
`Vary: Accept-Encoding`.
@@ -5,6 +5,7 @@ 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 dev.relism.fpr.core.ByteView;
import java.io.IOException;
import java.net.URI;
@@ -59,13 +60,22 @@ final class Assets {
Map.entry("otf", "font/otf"),
Map.entry("wasm", "application/wasm"));
record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {}
private static final byte[] HTML = ascii("text/html");
private static final byte[] GZIP_TOKEN = ascii("gzip");
private static final byte[] Q = ascii("q=");
record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, byte[] tag) {}
final Map<String, Asset> byPath;
private final Asset index;
private final boolean navigationOnly;
/** Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under {@code basePath}. */
Assets(ClassLoader loader, String basePath) {
/**
* Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under
* {@code basePath}; {@code navigationOnly} as in {@link ViteExtension#navigationOnly}.
*/
Assets(ClassLoader loader, String basePath, boolean navigationOnly) {
this.navigationOnly = navigationOnly;
URL index = loader.getResource(ViteExtension.CLASSPATH + "/index.html");
if (index == null) {
throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): "
@@ -107,22 +117,20 @@ final class Assets {
}
/**
* The asset at the request's path. A path that is none is a client-side route when a browser
* navigates to it ({@code Accept} names HTML), so it gets {@code index.html}; for anything
* else, an API call or a missing script, it is the app's own 404.
* The asset at the request's path. A path that is none gets {@code index.html} when a browser
* navigates to it, so the client-side router takes it; anything else, an API call or a missing
* script, gets the app's own 404. Headers are read as bytes, so nothing here allocates.
*/
void serve(Request req, Response res) {
Asset asset = byPath.get(req.path());
if (asset == null) {
String accept = req.header("Accept");
if (accept == null || !accept.contains("text/html")) throw HttpException.notFound(req.path());
if (navigationOnly && !navigation(req)) throw HttpException.notFound(req.path());
asset = index;
}
res.header(asset.cache);
if (asset.etag != null) {
res.header(asset.etag);
String known = req.header("If-None-Match");
if (known != null && known.contains(asset.tag)) {
if (indexOf(req.headerView("If-None-Match"), asset.tag, 0) >= 0) {
res.status(HttpStatus.NOT_MODIFIED);
return;
}
@@ -130,7 +138,7 @@ final class Assets {
res.type(asset.type);
if (asset.gzip != null) {
res.header(VARY);
if (acceptsGzip(req.header("Accept-Encoding"))) {
if (acceptsGzip(req.headerView("Accept-Encoding"))) {
res.header(GZIP).body(asset.gzip);
return;
}
@@ -138,33 +146,58 @@ final class Assets {
res.body(asset.raw);
}
/** What browsers send when a person opens a URL: {@code Sec-Fetch-Mode}, or {@code Accept} naming HTML for older clients. */
static boolean navigation(Request req) {
return req.headerEquals("Sec-Fetch-Mode", "navigate") || indexOf(req.headerView("Accept"), HTML, 0) >= 0;
}
/** 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");
static boolean acceptsGzip(ByteView accept) {
int at = indexOf(accept, GZIP_TOKEN, 0);
if (at < 0) return false;
int end = accept.indexOf(',', at);
if (end < 0) end = accept.length();
int q = accept.indexOf("q=", at);
int end = at;
while (end < accept.length() && accept.byteAt(end) != ',') end++;
int q = indexOf(accept, Q, at);
if (q < 0 || q > end) return true;
for (int i = q + 2; i < end; i++) {
char c = accept.charAt(i);
byte c = accept.byteAt(i);
if (c >= '1' && c <= '9') return true;
if (c != '0' && c != '.') break;
}
return false;
}
/**
* Where lowercase {@code needle} starts in {@code view} at or after {@code from}, ASCII case
* ignored, or -1 (also for a missing header). Folding with {@code | 0x20} leaves digits, quotes
* and slashes as they are, which is all the needles here contain besides letters.
*/
static int indexOf(ByteView view, byte[] needle, int from) {
if (view == null) return -1;
outer:
for (int i = from, last = view.length() - needle.length; i <= last; i++) {
for (int j = 0; j < needle.length; j++) {
if ((view.byteAt(i + j) | 0x20) != needle[j]) continue outer;
}
return i;
}
return -1;
}
/**
* Vite puts every content-hashed file under {@code assets/}: those never change, so they are
* cached for good and never revalidated, and need no ETag. The rest revalidate by one.
*/
private static Asset asset(String path, byte[] raw, byte[] gzip) {
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
byte[] type = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII);
byte[] type = ascii(TYPES.getOrDefault(extension, "application/octet-stream"));
if (path.startsWith("assets/")) return new Asset(raw, gzip, type, IMMUTABLE, null, null);
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), tag);
return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), ascii(tag));
}
private static byte[] ascii(String text) {
return text.getBytes(StandardCharsets.US_ASCII);
}
private static byte[] sha1(byte[] raw) {
@@ -20,6 +20,7 @@ public final class ViteExtension implements FlashExtension {
private Path root = Path.of("web");
private int devPort = 5173;
private String basePath = "/";
private boolean navigationOnly = true;
/** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */
public ViteExtension root(Path root) {
@@ -40,13 +41,23 @@ public final class ViteExtension implements FlashExtension {
return this;
}
/**
* Whether only a browser navigation to a path that is no file gets {@code index.html}, {@code
* true} by default, so an API call to a missing route gets a 404 rather than the page. {@code
* false} serves the page for every such {@code GET}, as nginx's {@code try_files} would.
*/
public ViteExtension navigationOnly(boolean navigationOnly) {
this.navigationOnly = navigationOnly;
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);
Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath, navigationOnly);
String everything = "/".equals(basePath) ? "/**" : basePath + "/**";
ctx.onReady(() -> {
app.get(everything, (req, res) -> {
@@ -1,5 +1,6 @@
package dev.relism.flash.ext.vite;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -33,24 +34,41 @@ class AssetsTest {
}
}
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());
assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/", true).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"));
assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/", true)).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"));
assertTrue(Assets.acceptsGzip(view("gzip, deflate, br")));
assertTrue(Assets.acceptsGzip(view("br;q=1.0, GZIP;q=0.5")));
assertFalse(Assets.acceptsGzip(view("gzip;q=0, br")));
assertFalse(Assets.acceptsGzip(view("gzip;q=0.000")));
assertFalse(Assets.acceptsGzip(view("br")));
assertFalse(Assets.acceptsGzip(null));
}
@Test
void searchingIgnoresAsciiCaseAndFindsTheFirstMatch() {
byte[] html = "text/html".getBytes();
assertEquals(0, Assets.indexOf(view("Text/HTML,*/*"), html, 0));
assertEquals(12, Assets.indexOf(view("application/text/html"), html, 0));
assertEquals(-1, Assets.indexOf(view("text/htm"), html, 0));
assertEquals(-1, Assets.indexOf(null, html, 0));
}
private static ByteView view(String text) {
byte[] bytes = text.getBytes();
return new ByteView() {
@Override public int length() { return bytes.length; }
@Override public byte byteAt(int i) { return bytes[i]; }
};
}
}
@@ -16,6 +16,9 @@ class ViteExtensionTest {
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension()));
@RegisterExtension
static final FlashTest everything = FlashTest.of(flash -> flash.install(new ViteExtension().navigationOnly(false)));
@RegisterExtension
static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/")));
@@ -41,6 +44,7 @@ class ViteExtensionTest {
for (String route : new String[]{"/content/2", "/users/ada.lovelace"}) {
app.request().header("Accept", NAVIGATION).get(route).expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa");
}
app.request().header("Sec-Fetch-Mode", "navigate").get("/content/2").expectStatus(200).expectBodyContains("spa");
app.request().header("Accept", "application/json").get("/api/nope").expectStatus(404);
app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404);
app.get("/content/2").expectStatus(404);
@@ -48,6 +52,13 @@ class ViteExtensionTest {
nested.get("/app").expectStatus(200).expectBodyContains("spa");
}
/** Opted out of the navigation check, every GET that is no file gets the page, as nginx's try_files would. */
@Test
void withoutTheNavigationCheckEveryMissGetsTheIndex() {
everything.request().header("Accept", "application/json").get("/api/nope").expectStatus(200).expectBodyContains("spa");
everything.get("/assets/missing.js").expectStatus(200).expectBodyContains("spa");
}
@Test
void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() {
String etag = app.get("/favicon.svg").header("ETag");
@@ -181,6 +181,12 @@ public class Request {
*/
public String header(String name) { checkActive(); return requestLine.getHeaders().first(name); }
/**
* The first value of header {@code name} as a view over the request's own bytes, or {@code null}:
* {@link #header} without the {@code String}, for a hot path. Valid only while the handler runs.
*/
public ByteView headerView(String name) { checkActive(); return requestLine.getHeaders().view(name); }
/**
* Returns all values of header {@code name} in declaration order.
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).