diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java index aa44a90..944ee9b 100644 --- a/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java @@ -8,22 +8,30 @@ import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; import java.util.List; +import java.util.Set; import java.util.stream.Stream; +import java.util.zip.Deflater; +import java.util.zip.GZIPOutputStream; /** * Builds the Vite project and packages the result where {@code ViteExtension} serves it from in - * production. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has - * its frontend. + * production, with a maximally gzipped {@code .gz} beside each text file, so the server compresses + * nothing. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has its + * frontend. */ @Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true) public final class BuildMojo extends AbstractMojo { + /** Formats that are not compressed already; images and woff gain nothing from gzip. */ + private static final Set COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm"); + /** The Vite project. */ @Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web") File root; @@ -61,7 +69,11 @@ public final class BuildMojo extends AbstractMojo { } Files.createDirectories(target.getParent()); try (Stream built = Files.walk(dist)) { - for (Path from : built.toList()) Files.copy(from, target.resolve(dist.relativize(from).toString())); + for (Path from : built.toList()) { + Path to = target.resolve(dist.relativize(from).toString()); + Files.copy(from, to); + if (Files.isRegularFile(to)) gzip(to); + } } } catch (IOException e) { throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e); @@ -69,6 +81,18 @@ public final class BuildMojo extends AbstractMojo { getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/"); } + /** Only worth it from 1 KB, and only kept when it is smaller. */ + private static void gzip(Path file) throws IOException { + String name = file.getFileName().toString(); + if (!COMPRESSIBLE.contains(name.substring(name.lastIndexOf('.') + 1).toLowerCase()) || Files.size(file) < 1024) return; + byte[] raw = Files.readAllBytes(file); + ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2); + try (GZIPOutputStream zip = new GZIPOutputStream(out) {{ def.setLevel(Deflater.BEST_COMPRESSION); }}) { + zip.write(raw); + } + if (out.size() < raw.length) Files.write(file.resolveSibling(name + ".gz"), out.toByteArray()); + } + private static void run(Path project, List command) throws MojoExecutionException { int exit; try { diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java index 5024766..f447882 100644 --- a/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java @@ -9,6 +9,7 @@ import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; /** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */ @@ -22,7 +23,7 @@ class BuildMojoTest { assumeTrue(onPath(), "npm is not on the PATH"); Path web = Files.createDirectories(dir.resolve("web")); Files.writeString(web.resolve("package.json"), """ - {"name":"t","private":true,"scripts":{"build":"node -e \\"const f=require('fs');f.mkdirSync('dist/assets',{recursive:true});f.writeFileSync('dist/index.html','built');f.writeFileSync('dist/assets/a-12345678.js','1')\\""}} + {"name":"t","private":true,"scripts":{"build":"node -e \\"const f=require('fs');f.mkdirSync('dist/assets',{recursive:true});f.writeFileSync('dist/index.html','built');f.writeFileSync('dist/assets/a-12345678.js','1');f.writeFileSync('dist/assets/b-12345678.js','x'.repeat(4096))\\""}} """); Path classes = dir.resolve("classes"); Files.createDirectories(classes.resolve("flash-vite")); @@ -33,6 +34,9 @@ class BuildMojoTest { assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html"))); assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js"))); assertFalse(Files.exists(classes.resolve("flash-vite/stale.js"))); + // Big enough to gzip, and it shrinks; the 1-byte file is left alone. + assertTrue(Files.size(classes.resolve("flash-vite/assets/b-12345678.js.gz")) < 4096); + assertFalse(Files.exists(classes.resolve("flash-vite/assets/a-12345678.js.gz"))); } @Test diff --git a/flash-extensions/flash-ext-vite/docs/README.md b/flash-extensions/flash-ext-vite/docs/README.md index a8a91ec..be3452e 100644 --- a/flash-extensions/flash-ext-vite/docs/README.md +++ b/flash-extensions/flash-ext-vite/docs/README.md @@ -91,29 +91,31 @@ server: { Anywhere `Flash.DEV` is false, the build is read once at boot from `flash-vite/` on the classpath, whether that is a directory (`target/classes`) or a jar. A missing build fails the boot and names -the plugin. Everything a response carries is prepared at boot, headers included, so serving -allocates nothing. +the plugin. Compression already happened in the build, so boot only reads files and hashes the few +that revalidate: about 50 ms for a 500-file app. Everything a response carries is prepared at +boot, headers included, so serving allocates nothing. `GET` and `HEAD` on `basePath/**` answer as follows. Backend routes still win, because Flash prefers specific routes over the wildcard. | Request | Answer | |---|---| -| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable` | +| `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` | -| a path with no file extension (`/content/2`) | `index.html`, so the client-side router takes it | -| a path with an extension that matches no file | `404` | +| 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 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 | -Text formats of 1 KB and more (html, js, css, json, svg, fonts, wasm, …) are gzipped once at boot -and sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with -`Vary: Accept-Encoding`. Images and woff are sent as they are, because they are compressed already. +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`. Known limits: -- A client-side route whose last segment has a dot (`/users/ada.lovelace`) is treated as a missing - file. Keep dots out of client routes, or route them under `basePath` differently. +- A browser navigating straight to an API path that does not exist gets the app, which shows its + own not-found screen. Only an HTML navigation falls back, so API clients always get the 404. - Every file is held in memory, gzip included. That suits an app's own frontend. Large media belongs on a CDN or behind its own route. @@ -124,7 +126,10 @@ The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node, 1. installs the dependencies with the lockfile pinned; 2. runs ` run build` in the project; -3. replaces `target/classes/flash-vite/` with the project's `dist/`. +3. replaces `target/classes/flash-vite/` with the project's `dist/`; +4. writes a gzip `.gz` at maximum compression beside every html, js, css, json, map, svg, txt, + xml, webmanifest, font and wasm file of 1 KB and more, and keeps it only when it is smaller. + Images and woff are left alone, because they are compressed already. Missing Node or package manager, a failing install or build, or a build that leaves no `dist/index.html` fails the Maven build with the reason. The package manager's own output shows in 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 09b5730..5f34a98 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 @@ -1,13 +1,12 @@ package dev.relism.flash.ext.vite; +import dev.relism.flash.exceptions.HttpException; 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 java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UncheckedIOException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -22,13 +21,12 @@ import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.HexFormat; import java.util.Map; -import java.util.Set; import java.util.stream.Stream; -import java.util.zip.GZIPOutputStream; /** * The built frontend, read once from the classpath at boot and served from memory: every byte a - * response carries, headers included, is prepared here, so serving allocates nothing. + * response carries, headers included, is prepared here, so serving allocates nothing. Compression + * happened at build time: a {@code .gz} beside a file is its gzipped form. */ final class Assets { @@ -61,9 +59,6 @@ final class Assets { Map.entry("otf", "font/otf"), Map.entry("wasm", "application/wasm")); - /** Formats that are not compressed already; the rest (images, woff) gain nothing from gzip. */ - private static final Set COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm"); - record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {} final Map byPath; @@ -92,7 +87,10 @@ final class Assets { try (Stream files = Files.walk(root)) { for (Path file : files.filter(Files::isRegularFile).toList()) { String relative = root.relativize(file).toString().replace('\\', '/'); - assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file))); + if (relative.endsWith(".gz")) continue; + Path gzip = file.resolveSibling(file.getFileName() + ".gz"); + assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file), + Files.isRegularFile(gzip) ? Files.readAllBytes(gzip) : null)); } } finally { if (opened != null) opened.close(); @@ -100,29 +98,34 @@ final class Assets { } catch (IOException | URISyntaxException e) { throw new IllegalStateException("Cannot read the built frontend at " + index, e); } + // The root is the app itself, whatever the client accepts. + Asset root = assets.get(prefix + "/index.html"); + assets.put(prefix + "/", root); + if (!prefix.isEmpty()) assets.put(prefix, root); this.byPath = Map.copyOf(assets); - this.index = byPath.get(prefix + "/index.html"); + this.index = root; } /** - * The asset at the request's path; a path with no file extension is a client-side route and - * gets {@code index.html}, one with an extension that matches nothing is a 404. + * 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. */ void serve(Request req, Response res) { - String path = req.path(); - Asset asset = byPath.get(path); + Asset asset = byPath.get(req.path()); if (asset == null) { - if (path.lastIndexOf('.') > path.lastIndexOf('/')) { - res.status(HttpStatus.NOT_FOUND); - return; - } + String accept = req.header("Accept"); + if (accept == null || !accept.contains("text/html")) throw HttpException.notFound(req.path()); asset = index; } - res.header(asset.cache).header(asset.etag); - String known = req.header("If-None-Match"); - if (known != null && known.contains(asset.tag)) { - res.status(HttpStatus.NOT_MODIFIED); - return; + 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)) { + res.status(HttpStatus.NOT_MODIFIED); + return; + } } res.type(asset.type); if (asset.gzip != null) { @@ -152,24 +155,16 @@ final class Assets { return false; } - /** Vite puts every content-hashed file under {@code assets/}: those never change, the rest revalidate. */ - private static Asset asset(String path, byte[] raw) { + /** + * 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[] gzip = COMPRESSIBLE.contains(extension) && raw.length >= 1024 ? gzip(raw) : null; + 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 != null && gzip.length < raw.length ? gzip : null, type, - path.startsWith("assets/") ? IMMUTABLE : REVALIDATE, new PreEncodedHeader("ETag", tag), tag); - } - - private static byte[] gzip(byte[] raw) { - ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2); - try (GZIPOutputStream zip = new GZIPOutputStream(out)) { - zip.write(raw); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - return out.toByteArray(); + return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), tag); } private static byte[] sha1(byte[] raw) { 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 a1a6720..980d34e 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 @@ -33,7 +33,7 @@ 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, "/").byPath.keySet()); } } 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 2c3a3e5..fe9cd16 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 @@ -11,20 +11,14 @@ import static org.junit.jupiter.api.Assertions.assertNull; class ViteExtensionTest { private static final String JS = "/assets/app-AbCd1234.js"; + private static final String NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; @RegisterExtension - static final FlashTest app = FlashTest.of(flash -> flash.get("/api/before", (req, res) -> "before") - .install(new ViteExtension()).get("/api/ping", (req, res) -> "pong")); + static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension())); @RegisterExtension static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/"))); - @Test - void backendRoutesWin() { - app.get("/api/ping").expectStatus(200).expectBody("pong"); - app.get("/api/before").expectStatus(200).expectBody("before"); - } - @Test void hashedAssetsAreCachedForeverTheRestRevalidates() { app.get(JS).expectStatus(200) @@ -41,17 +35,25 @@ class ViteExtensionTest { assertNull(app.get(JS).header("Content-Encoding")); } + /** A browser navigating gets the app, dots and all; a fetch or a script tag for nothing gets a 404. */ @Test - void clientRoutesGetTheIndexMissingFilesA404() { - app.get("/content/2").expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa"); - app.get("/assets/missing.js").expectStatus(404); + void onlyNavigationsFallBackToTheIndex() { + 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("Accept", "application/json").get("/api/nope").expectStatus(404); + app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404); + app.get("/content/2").expectStatus(404); + app.get("/").expectStatus(200).expectBodyContains("spa"); + nested.get("/app").expectStatus(200).expectBodyContains("spa"); } @Test - void anUnchangedAssetIsNotSentAgain() { - String etag = app.get(JS).header("ETag"); - app.request().header("If-None-Match", etag).get(JS).expectStatus(304) - .expectHeader("ETag", etag).expectHeader("Cache-Control", "public, max-age=31536000, immutable"); + void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() { + String etag = app.get("/favicon.svg").header("ETag"); + app.request().header("If-None-Match", etag).get("/favicon.svg").expectStatus(304) + .expectHeader("ETag", etag).expectHeader("Cache-Control", "no-cache"); + assertNull(app.get(JS).header("ETag")); } @Test @@ -64,7 +66,7 @@ class ViteExtensionTest { @Test void aBasePathPrefixesEveryRoute() { nested.get("/app" + JS).expectStatus(200); - nested.get("/app/settings").expectStatus(200).expectBodyContains("spa"); + nested.request().header("Accept", NAVIGATION).get("/app/settings").expectStatus(200).expectBodyContains("spa"); nested.get(JS).expectStatus(404); } } diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz new file mode 100644 index 0000000..65101b6 Binary files /dev/null and b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz differ