feat(ext-vite): recognise navigations by Sec-Fetch-Mode, read every header in place

A path that is no file falls back to index.html when the request is a navigation:
Sec-Fetch-Mode: navigate, or an Accept naming text/html for older clients. That check,
Accept-Encoding and If-None-Match are all matched on the header bytes through the new
Request.headerView(name), so serving still allocates nothing. navigationOnly(false)
drops the check for an app that wants every GET miss to get the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 16:16:27 +00:00
co-authored by Claude Opus 5
parent 680bbca8c7
commit 003fd6d1f0
6 changed files with 116 additions and 29 deletions
@@ -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}).