feat: Vite extension and Maven plugin; data pools close on stop #19
+27
-3
@@ -8,22 +8,30 @@ import org.apache.maven.plugins.annotations.LifecyclePhase;
|
|||||||
import org.apache.maven.plugins.annotations.Mojo;
|
import org.apache.maven.plugins.annotations.Mojo;
|
||||||
import org.apache.maven.plugins.annotations.Parameter;
|
import org.apache.maven.plugins.annotations.Parameter;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Stream;
|
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
|
* 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
|
* production, with a maximally gzipped {@code .gz} beside each text file, so the server compresses
|
||||||
* its frontend.
|
* 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)
|
@Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true)
|
||||||
public final class BuildMojo extends AbstractMojo {
|
public final class BuildMojo extends AbstractMojo {
|
||||||
|
|
||||||
|
/** Formats that are not compressed already; images and woff gain nothing from gzip. */
|
||||||
|
private static final Set<String> COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm");
|
||||||
|
|
||||||
/** The Vite project. */
|
/** The Vite project. */
|
||||||
@Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
|
@Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
|
||||||
File root;
|
File root;
|
||||||
@@ -61,7 +69,11 @@ public final class BuildMojo extends AbstractMojo {
|
|||||||
}
|
}
|
||||||
Files.createDirectories(target.getParent());
|
Files.createDirectories(target.getParent());
|
||||||
try (Stream<Path> built = Files.walk(dist)) {
|
try (Stream<Path> 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) {
|
} catch (IOException e) {
|
||||||
throw new MojoExecutionException("Cannot copy " + dist + " to " + target, 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 + "/");
|
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<String> command) throws MojoExecutionException {
|
private static void run(Path project, List<String> command) throws MojoExecutionException {
|
||||||
int exit;
|
int exit;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,7 @@ import java.nio.file.Path;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
|
|
||||||
/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */
|
/** 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");
|
assumeTrue(onPath(), "npm is not on the PATH");
|
||||||
Path web = Files.createDirectories(dir.resolve("web"));
|
Path web = Files.createDirectories(dir.resolve("web"));
|
||||||
Files.writeString(web.resolve("package.json"), """
|
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");
|
Path classes = dir.resolve("classes");
|
||||||
Files.createDirectories(classes.resolve("flash-vite"));
|
Files.createDirectories(classes.resolve("flash-vite"));
|
||||||
@@ -33,6 +34,9 @@ class BuildMojoTest {
|
|||||||
assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html")));
|
assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html")));
|
||||||
assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js")));
|
assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js")));
|
||||||
assertFalse(Files.exists(classes.resolve("flash-vite/stale.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
|
@Test
|
||||||
|
|||||||
@@ -91,29 +91,31 @@ server: {
|
|||||||
|
|
||||||
Anywhere `Flash.DEV` is false, the build is read once at boot from `flash-vite/` on the classpath,
|
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
|
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
|
the plugin. Compression already happened in the build, so boot only reads files and hashes the few
|
||||||
allocates nothing.
|
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
|
`GET` and `HEAD` on `basePath/**` answer as follows. Backend routes still win, because Flash
|
||||||
prefers specific routes over the wildcard.
|
prefers specific routes over the wildcard.
|
||||||
|
|
||||||
| Request | Answer |
|
| 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` |
|
| 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 |
|
| no such file, and `Accept` names `text/html` (a browser navigating to `/content/2`) | `index.html`, so the client-side router takes it |
|
||||||
| a path with an extension that matches no file | `404` |
|
| 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 |
|
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body |
|
||||||
| `HEAD` | the `GET` headers, `Content-Length` included, 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
|
A `.gz` beside a file is its gzipped form: the plugin writes one for every text file of 1 KB and
|
||||||
and sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
|
more, and it is 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.
|
`Vary: Accept-Encoding`.
|
||||||
|
|
||||||
Known limits:
|
Known limits:
|
||||||
|
|
||||||
- A client-side route whose last segment has a dot (`/users/ada.lovelace`) is treated as a missing
|
- A browser navigating straight to an API path that does not exist gets the app, which shows its
|
||||||
file. Keep dots out of client routes, or route them under `basePath` differently.
|
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
|
- 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.
|
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;
|
1. installs the dependencies with the lockfile pinned;
|
||||||
2. runs `<pm> run build` in the project;
|
2. runs `<pm> 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
|
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
|
`dist/index.html` fails the Maven build with the reason. The package manager's own output shows in
|
||||||
|
|||||||
+29
-34
@@ -1,13 +1,12 @@
|
|||||||
package dev.relism.flash.ext.vite;
|
package dev.relism.flash.ext.vite;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
import dev.relism.flash.http.HttpStatus;
|
import dev.relism.flash.http.HttpStatus;
|
||||||
import dev.relism.flash.models.PreEncodedHeader;
|
import dev.relism.flash.models.PreEncodedHeader;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.UncheckedIOException;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
@@ -22,13 +21,12 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Stream;
|
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
|
* 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 {
|
final class Assets {
|
||||||
|
|
||||||
@@ -61,9 +59,6 @@ final class Assets {
|
|||||||
Map.entry("otf", "font/otf"),
|
Map.entry("otf", "font/otf"),
|
||||||
Map.entry("wasm", "application/wasm"));
|
Map.entry("wasm", "application/wasm"));
|
||||||
|
|
||||||
/** Formats that are not compressed already; the rest (images, woff) gain nothing from gzip. */
|
|
||||||
private static final Set<String> 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) {}
|
record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {}
|
||||||
|
|
||||||
final Map<String, Asset> byPath;
|
final Map<String, Asset> byPath;
|
||||||
@@ -92,7 +87,10 @@ final class Assets {
|
|||||||
try (Stream<Path> files = Files.walk(root)) {
|
try (Stream<Path> files = Files.walk(root)) {
|
||||||
for (Path file : files.filter(Files::isRegularFile).toList()) {
|
for (Path file : files.filter(Files::isRegularFile).toList()) {
|
||||||
String relative = root.relativize(file).toString().replace('\\', '/');
|
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 {
|
} finally {
|
||||||
if (opened != null) opened.close();
|
if (opened != null) opened.close();
|
||||||
@@ -100,30 +98,35 @@ final class Assets {
|
|||||||
} catch (IOException | URISyntaxException e) {
|
} catch (IOException | URISyntaxException e) {
|
||||||
throw new IllegalStateException("Cannot read the built frontend at " + index, 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.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
|
* The asset at the request's path. A path that is none is a client-side route when a browser
|
||||||
* gets {@code index.html}, one with an extension that matches nothing is a 404.
|
* 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) {
|
void serve(Request req, Response res) {
|
||||||
String path = req.path();
|
Asset asset = byPath.get(req.path());
|
||||||
Asset asset = byPath.get(path);
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
if (path.lastIndexOf('.') > path.lastIndexOf('/')) {
|
String accept = req.header("Accept");
|
||||||
res.status(HttpStatus.NOT_FOUND);
|
if (accept == null || !accept.contains("text/html")) throw HttpException.notFound(req.path());
|
||||||
return;
|
|
||||||
}
|
|
||||||
asset = index;
|
asset = index;
|
||||||
}
|
}
|
||||||
res.header(asset.cache).header(asset.etag);
|
res.header(asset.cache);
|
||||||
|
if (asset.etag != null) {
|
||||||
|
res.header(asset.etag);
|
||||||
String known = req.header("If-None-Match");
|
String known = req.header("If-None-Match");
|
||||||
if (known != null && known.contains(asset.tag)) {
|
if (known != null && known.contains(asset.tag)) {
|
||||||
res.status(HttpStatus.NOT_MODIFIED);
|
res.status(HttpStatus.NOT_MODIFIED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
res.type(asset.type);
|
res.type(asset.type);
|
||||||
if (asset.gzip != null) {
|
if (asset.gzip != null) {
|
||||||
res.header(VARY);
|
res.header(VARY);
|
||||||
@@ -152,24 +155,16 @@ final class Assets {
|
|||||||
return false;
|
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();
|
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
|
||||||
byte[] type = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII);
|
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)) + "\"";
|
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
|
||||||
return new Asset(raw, gzip != null && gzip.length < raw.length ? gzip : null, type,
|
return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), tag);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] sha1(byte[] raw) {
|
private static byte[] sha1(byte[] raw) {
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ class AssetsTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) {
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-16
@@ -11,20 +11,14 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
|||||||
class ViteExtensionTest {
|
class ViteExtensionTest {
|
||||||
|
|
||||||
private static final String JS = "/assets/app-AbCd1234.js";
|
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
|
@RegisterExtension
|
||||||
static final FlashTest app = FlashTest.of(flash -> flash.get("/api/before", (req, res) -> "before")
|
static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension()));
|
||||||
.install(new ViteExtension()).get("/api/ping", (req, res) -> "pong"));
|
|
||||||
|
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/")));
|
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
|
@Test
|
||||||
void hashedAssetsAreCachedForeverTheRestRevalidates() {
|
void hashedAssetsAreCachedForeverTheRestRevalidates() {
|
||||||
app.get(JS).expectStatus(200)
|
app.get(JS).expectStatus(200)
|
||||||
@@ -41,17 +35,25 @@ class ViteExtensionTest {
|
|||||||
assertNull(app.get(JS).header("Content-Encoding"));
|
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
|
@Test
|
||||||
void clientRoutesGetTheIndexMissingFilesA404() {
|
void onlyNavigationsFallBackToTheIndex() {
|
||||||
app.get("/content/2").expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa");
|
for (String route : new String[]{"/content/2", "/users/ada.lovelace"}) {
|
||||||
app.get("/assets/missing.js").expectStatus(404);
|
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
|
@Test
|
||||||
void anUnchangedAssetIsNotSentAgain() {
|
void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() {
|
||||||
String etag = app.get(JS).header("ETag");
|
String etag = app.get("/favicon.svg").header("ETag");
|
||||||
app.request().header("If-None-Match", etag).get(JS).expectStatus(304)
|
app.request().header("If-None-Match", etag).get("/favicon.svg").expectStatus(304)
|
||||||
.expectHeader("ETag", etag).expectHeader("Cache-Control", "public, max-age=31536000, immutable");
|
.expectHeader("ETag", etag).expectHeader("Cache-Control", "no-cache");
|
||||||
|
assertNull(app.get(JS).header("ETag"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -64,7 +66,7 @@ class ViteExtensionTest {
|
|||||||
@Test
|
@Test
|
||||||
void aBasePathPrefixesEveryRoute() {
|
void aBasePathPrefixesEveryRoute() {
|
||||||
nested.get("/app" + JS).expectStatus(200);
|
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);
|
nested.get(JS).expectStatus(404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user