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:
co-authored by
Claude Opus 5
parent
680bbca8c7
commit
003fd6d1f0
+53
-20
@@ -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) {
|
||||
|
||||
+12
-1
@@ -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) -> {
|
||||
|
||||
+25
-7
@@ -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]; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user