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}).