From 003fd6d1f0b822e8df9c82c0635265989c221998 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Tue, 22 Sep 2026 16:16:27 +0000 Subject: [PATCH] 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 --- .../flash-ext-vite/docs/README.md | 10 ++- .../dev/relism/flash/ext/vite/Assets.java | 73 ++++++++++++++----- .../relism/flash/ext/vite/ViteExtension.java | 13 +++- .../dev/relism/flash/ext/vite/AssetsTest.java | 32 ++++++-- .../flash/ext/vite/ViteExtensionTest.java | 11 +++ .../java/dev/relism/flash/models/Request.java | 6 ++ 6 files changed, 116 insertions(+), 29 deletions(-) diff --git a/flash-extensions/flash-ext-vite/docs/README.md b/flash-extensions/flash-ext-vite/docs/README.md index be3452e..1490851 100644 --- a/flash-extensions/flash-ext-vite/docs/README.md +++ b/flash-extensions/flash-ext-vite/docs/README.md @@ -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`. diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java index 5f34a98..245e232 100644 --- a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java @@ -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 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) { diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java index 65f4aa1..897663d 100644 --- a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java @@ -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) -> { diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java index 980d34e..4e0552e 100644 --- a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java @@ -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]; } + }; + } } diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java index fe9cd16..de3ab29 100644 --- a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java @@ -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"); diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index d7597dd..4b45d25 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -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}).