diff --git a/.idea/encodings.xml b/.idea/encodings.xml
index 278fb63..aac5826 100644
--- a/.idea/encodings.xml
+++ b/.idea/encodings.xml
@@ -31,8 +31,8 @@
-
-
+
+
diff --git a/AGENTS.md b/AGENTS.md
index cb99dad..d949708 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,7 @@ Format: `(): `
| `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
-`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
+`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`,
`ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
diff --git a/README.md b/README.md
index 6c1c367..a724514 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
+| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
+| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
diff --git a/flash-extensions/flash-ext-vite-maven-plugin/pom.xml b/flash-extensions/flash-ext-vite-maven-plugin/pom.xml
new file mode 100644
index 0000000..908fdb4
--- /dev/null
+++ b/flash-extensions/flash-ext-vite-maven-plugin/pom.xml
@@ -0,0 +1,51 @@
+
+
+ 4.0.0
+
+
+ dev.relism
+ flash-extensions
+ 2.1.0-SNAPSHOT
+
+
+ flash-ext-vite-maven-plugin
+ maven-plugin
+
+
+
+ dev.relism
+ flash-ext-vite
+
+
+ org.apache.maven
+ maven-plugin-api
+ 3.9.9
+ provided
+
+
+ org.apache.maven.plugin-tools
+ maven-plugin-annotations
+ 3.15.1
+ provided
+
+
+ org.junit.jupiter
+ junit-jupiter
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-plugin-plugin
+ 3.15.1
+
+ flash-vite
+
+
+
+
+
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
new file mode 100644
index 0000000..aa44a90
--- /dev/null
+++ b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java
@@ -0,0 +1,84 @@
+package dev.relism.flash.ext.vite.maven;
+
+import dev.relism.flash.ext.vite.PackageManager;
+import dev.relism.flash.ext.vite.ViteExtension;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+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.stream.Stream;
+
+/**
+ * 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.
+ */
+@Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true)
+public final class BuildMojo extends AbstractMojo {
+
+ /** The Vite project. */
+ @Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
+ File root;
+
+ @Parameter(defaultValue = "${project.build.outputDirectory}", readonly = true, required = true)
+ File classes;
+
+ /** Leaves the frontend out, for a build that only needs the backend. */
+ @Parameter(property = "flash.vite.skip", defaultValue = "false")
+ boolean skip;
+
+ @Override
+ public void execute() throws MojoExecutionException {
+ if (skip) {
+ getLog().info("Skipping the frontend build");
+ return;
+ }
+ Path project = root.toPath();
+ if (!Files.isRegularFile(project.resolve("package.json"))) {
+ throw new MojoExecutionException("No package.json in " + project + ": set to the Vite project.");
+ }
+ PackageManager.Found packages = PackageManager.of(project);
+ run(project, packages.install());
+ run(project, packages.run("build"));
+ Path dist = project.resolve("dist");
+ if (!Files.isRegularFile(dist.resolve("index.html"))) {
+ throw new MojoExecutionException("The build left no dist/index.html in " + project + ".");
+ }
+ Path target = classes.toPath().resolve(ViteExtension.CLASSPATH);
+ try {
+ if (Files.exists(target)) {
+ try (Stream old = Files.walk(target)) {
+ for (Path path : old.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
+ }
+ }
+ Files.createDirectories(target.getParent());
+ try (Stream built = Files.walk(dist)) {
+ for (Path from : built.toList()) Files.copy(from, target.resolve(dist.relativize(from).toString()));
+ }
+ } catch (IOException e) {
+ throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e);
+ }
+ getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/");
+ }
+
+ private static void run(Path project, List command) throws MojoExecutionException {
+ int exit;
+ try {
+ exit = new ProcessBuilder(command).directory(project.toFile()).inheritIO().start().waitFor();
+ } catch (IOException e) {
+ throw new MojoExecutionException("Cannot run " + command.getFirst() + ": is Node installed and " + command.getFirst() + " on the PATH?", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new MojoExecutionException("Interrupted running " + String.join(" ", command), e);
+ }
+ if (exit != 0) throw new MojoExecutionException(String.join(" ", command) + " failed with " + exit + " (its output is above).");
+ }
+}
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
new file mode 100644
index 0000000..5024766
--- /dev/null
+++ b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java
@@ -0,0 +1,59 @@
+package dev.relism.flash.ext.vite.maven;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+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.Assumptions.assumeTrue;
+
+/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */
+class BuildMojoTest {
+
+ @TempDir
+ Path dir;
+
+ @Test
+ void theBuildLandsWhereTheExtensionServesItFrom() throws Exception {
+ 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')\\""}}
+ """);
+ Path classes = dir.resolve("classes");
+ Files.createDirectories(classes.resolve("flash-vite"));
+ Files.writeString(classes.resolve("flash-vite/stale.js"), "from the last build");
+
+ mojo(web, classes, false).execute();
+
+ 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")));
+ }
+
+ @Test
+ void skipBuildsNothing() throws Exception {
+ mojo(dir.resolve("nowhere"), dir.resolve("classes"), true).execute();
+ assertFalse(Files.exists(dir.resolve("classes")));
+ }
+
+ private static BuildMojo mojo(Path root, Path classes, boolean skip) {
+ BuildMojo mojo = new BuildMojo();
+ mojo.root = root.toFile();
+ mojo.classes = classes.toFile();
+ mojo.skip = skip;
+ return mojo;
+ }
+
+ private static boolean onPath() {
+ try {
+ return new ProcessBuilder("npm", "--version").start().waitFor() == 0;
+ } catch (IOException | InterruptedException e) {
+ return false;
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-vite/docs/README.md b/flash-extensions/flash-ext-vite/docs/README.md
new file mode 100644
index 0000000..a8a91ec
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/docs/README.md
@@ -0,0 +1,134 @@
+# flash-ext-vite
+
+A Vite frontend for a Flash app, in two artifacts:
+
+- **`flash-ext-vite`**, the extension. In DEV it runs Vite's dev server beside the app. Anywhere
+ else it serves the built frontend from the classpath as a single-page app.
+- **`flash-ext-vite-maven-plugin`**, the build. It builds the frontend during `mvn package` and puts
+ the result where the extension reads it, so the jar runs on its own.
+
+## Quick start
+
+A Vite project in `web/` with the template's `dev` and `build` scripts, and:
+
+```java
+FlashApp.create(8080)
+ .install(new ViteExtension())
+ .get("/api/hello", (req, res) -> "hi")
+ .start();
+```
+
+```xml
+
+ dev.relism
+ flash-ext-vite-maven-plugin
+ ${flash.version}
+
+
+ build
+
+
+
+```
+
+That is the whole setup. `FLASH_ENV=dev` gives you Vite with hot reload, and `mvn package` gives you
+a jar that serves the frontend.
+
+## Conventions
+
+These are fixed on purpose: each one is the Vite default, or the thing that keeps the two artifacts
+in agreement.
+
+| | |
+|---|---|
+| Project | a Vite project whose `package.json` has a `dev` and a `build` script |
+| Build output | `dist/` inside the project (Vite's `build.outDir` default) |
+| Inside the jar | `flash-vite/` (`ViteExtension.CLASSPATH`) |
+| Content-hashed files | everything under `assets/` (Vite's `build.assetsDir` default) |
+| Package manager | the one whose lockfile is nearest at or above the project: `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json`. With none, npm. A project inside a workspace therefore uses the workspace's lockfile. |
+| Node | on the `PATH`, with the package manager. Nothing is downloaded. |
+
+## What you can override
+
+| Extension | Default | |
+|---|---|---|
+| `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. |
+
+| Plugin parameter | Default | |
+|---|---|---|
+| `root` / `-Dflash.vite.root` | `${project.basedir}/web` | The Vite project. |
+| `skip` / `-Dflash.vite.skip` | `false` | Leaves the frontend out, for a backend-only build. |
+
+There is nothing else to set. If a convention above does not fit, that is a change to this module,
+not a configuration option.
+
+## DEV
+
+`Flash.DEV` (`FLASH_ENV=dev` or `-Dflash.env=dev`) decides the mode:
+
+1. The dependencies are installed with the lockfile pinned (`pnpm install --frozen-lockfile`,
+ `npm ci`, and so on). This is skipped when `node_modules` was already installed from that same
+ lockfile: its hash is kept in `node_modules/.flash-vite`, so deleting `node_modules` resets it.
+2. The extension runs ` run dev --host 127.0.0.1 --port --strictPort` and waits up to
+ 30 s for the port to answer. A port already in use, a script that exits, or a timeout fails the
+ boot with the reason. Vite's output is logged at INFO, prefixed `[vite]`.
+3. While the app runs, a change to the lockfile (`pnpm add …`) reinstalls and restarts Vite. Vite
+ handles changes to its own config itself.
+4. When the app stops (`ctx.onClose`), Vite and its whole process tree stop with it.
+
+The extension registers no routes in DEV. The browser opens Vite's port, and Vite forwards the
+backend's paths to Flash, so `vite.config` needs a `server.proxy` for them:
+
+```ts
+server: {
+ proxy: { '^/(api|auth)(/|$)': 'http://localhost:8080' },
+},
+```
+
+## Production
+
+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.
+
+`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` |
+| 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` |
+| `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.
+
+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.
+- 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.
+
+## The plugin
+
+The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node, and `mvn package`,
+`verify` and `install` always produce a jar with its frontend. It:
+
+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/`.
+
+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
+the Maven log.
+
+A Docker image therefore needs one build stage with both a JDK and Node. The frontend does not need
+its own stage.
diff --git a/flash-extensions/flash-ext-web-bundler/pom.xml b/flash-extensions/flash-ext-vite/pom.xml
similarity index 75%
rename from flash-extensions/flash-ext-web-bundler/pom.xml
rename to flash-extensions/flash-ext-vite/pom.xml
index 45ffc5a..fc375a2 100644
--- a/flash-extensions/flash-ext-web-bundler/pom.xml
+++ b/flash-extensions/flash-ext-vite/pom.xml
@@ -10,25 +10,17 @@
2.1.0-SNAPSHOT
- flash-ext-web-bundler
+ flash-ext-vite
dev.relism
flash
-
- org.projectlombok
- lombok
-
org.slf4j
slf4j-api
-
- com.fasterxml.jackson.core
- jackson-databind
-
org.junit.jupiter
junit-jupiter
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
new file mode 100644
index 0000000..09b5730
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java
@@ -0,0 +1,182 @@
+package dev.relism.flash.ext.vite;
+
+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;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystemAlreadyExistsException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+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.
+ */
+final class Assets {
+
+ private static final PreEncodedHeader IMMUTABLE = new PreEncodedHeader("Cache-Control", "public, max-age=31536000, immutable");
+ private static final PreEncodedHeader REVALIDATE = new PreEncodedHeader("Cache-Control", "no-cache");
+ private static final PreEncodedHeader VARY = new PreEncodedHeader("Vary", "Accept-Encoding");
+ private static final PreEncodedHeader GZIP = new PreEncodedHeader("Content-Encoding", "gzip");
+
+ private static final Map TYPES = Map.ofEntries(
+ Map.entry("html", "text/html; charset=utf-8"),
+ Map.entry("js", "text/javascript; charset=utf-8"),
+ Map.entry("mjs", "text/javascript; charset=utf-8"),
+ Map.entry("css", "text/css; charset=utf-8"),
+ Map.entry("json", "application/json"),
+ Map.entry("map", "application/json"),
+ Map.entry("webmanifest", "application/manifest+json"),
+ Map.entry("txt", "text/plain; charset=utf-8"),
+ Map.entry("xml", "application/xml"),
+ Map.entry("svg", "image/svg+xml"),
+ Map.entry("png", "image/png"),
+ Map.entry("jpg", "image/jpeg"),
+ Map.entry("jpeg", "image/jpeg"),
+ Map.entry("gif", "image/gif"),
+ Map.entry("webp", "image/webp"),
+ Map.entry("avif", "image/avif"),
+ Map.entry("ico", "image/x-icon"),
+ Map.entry("woff", "font/woff"),
+ Map.entry("woff2", "font/woff2"),
+ Map.entry("ttf", "font/ttf"),
+ 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;
+ private final Asset index;
+
+ /** Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under {@code basePath}. */
+ Assets(ClassLoader loader, String basePath) {
+ URL index = loader.getResource(ViteExtension.CLASSPATH + "/index.html");
+ if (index == null) {
+ throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): "
+ + "package the application with flash-ext-vite-maven-plugin, or run it with FLASH_ENV=dev.");
+ }
+ String prefix = "/".equals(basePath) ? "" : basePath;
+ Map assets = new HashMap<>();
+ try {
+ URI uri = index.toURI();
+ FileSystem opened = null;
+ if ("jar".equals(uri.getScheme())) {
+ try {
+ opened = FileSystems.newFileSystem(uri, Map.of());
+ } catch (FileSystemAlreadyExistsException alreadyOpen) {
+ // Path.of below resolves against the one already open.
+ }
+ }
+ Path root = Path.of(uri).getParent();
+ 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)));
+ }
+ } finally {
+ if (opened != null) opened.close();
+ }
+ } catch (IOException | URISyntaxException e) {
+ throw new IllegalStateException("Cannot read the built frontend at " + index, e);
+ }
+ this.byPath = Map.copyOf(assets);
+ this.index = byPath.get(prefix + "/index.html");
+ }
+
+ /**
+ * 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.
+ */
+ void serve(Request req, Response res) {
+ String path = req.path();
+ Asset asset = byPath.get(path);
+ if (asset == null) {
+ if (path.lastIndexOf('.') > path.lastIndexOf('/')) {
+ res.status(HttpStatus.NOT_FOUND);
+ return;
+ }
+ 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.type(asset.type);
+ if (asset.gzip != null) {
+ res.header(VARY);
+ if (acceptsGzip(req.header("Accept-Encoding"))) {
+ res.header(GZIP).body(asset.gzip);
+ return;
+ }
+ }
+ res.body(asset.raw);
+ }
+
+ /** 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");
+ if (at < 0) return false;
+ int end = accept.indexOf(',', at);
+ if (end < 0) end = accept.length();
+ int q = accept.indexOf("q=", at);
+ if (q < 0 || q > end) return true;
+ for (int i = q + 2; i < end; i++) {
+ char c = accept.charAt(i);
+ if (c >= '1' && c <= '9') return true;
+ if (c != '0' && c != '.') break;
+ }
+ 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) {
+ 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;
+ 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();
+ }
+
+ private static byte[] sha1(byte[] raw) {
+ try {
+ return MessageDigest.getInstance("SHA-1").digest(raw);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java
new file mode 100644
index 0000000..85f900d
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java
@@ -0,0 +1,173 @@
+package dev.relism.flash.ext.vite;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.UncheckedIOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Vite's dev server as a child process of the app: dependencies installed whenever the lockfile
+ * differs from the one they were installed from, the server restarted when it changes under it,
+ * and the whole process tree gone when the app stops.
+ */
+final class DevServer implements AutoCloseable {
+ private static final Logger log = LoggerFactory.getLogger(DevServer.class);
+
+ private final Path root;
+ private final int port;
+ private final PackageManager.Found packages;
+ private final ScheduledExecutorService watch = Executors.newSingleThreadScheduledExecutor(
+ Thread.ofPlatform().daemon().name("flash-vite-watch").factory());
+ private Process process;
+ private FileTime lockfileTime;
+
+ DevServer(Path root, int port) {
+ if (!Files.isRegularFile(root.resolve("package.json"))) {
+ throw new IllegalStateException("No package.json in " + root.toAbsolutePath() + ": point ViteExtension.root(...) at the Vite project.");
+ }
+ this.root = root;
+ this.port = port;
+ this.packages = PackageManager.of(root);
+ lockfileTime = modified();
+ try {
+ install();
+ start();
+ } catch (RuntimeException failed) {
+ close();
+ throw failed;
+ }
+ if (packages.lockfile() != null) watch.scheduleWithFixedDelay(this::restartIfLockfileChanged, 2, 2, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public synchronized void close() {
+ watch.shutdownNow();
+ stop();
+ }
+
+ private synchronized void restartIfLockfileChanged() {
+ FileTime now = modified();
+ if (now.equals(lockfileTime)) return;
+ lockfileTime = now;
+ log.info("{} changed, reinstalling and restarting Vite", packages.lockfile().getFileName());
+ try {
+ stop();
+ install();
+ start();
+ } catch (RuntimeException failed) {
+ log.error("Vite did not come back; fix the cause and touch the lockfile to retry", failed);
+ }
+ }
+
+ /** Skipped when {@code node_modules} was installed from this very lockfile. */
+ private void install() {
+ Path stamp = root.resolve("node_modules/.flash-vite");
+ String lockfile = packages.lockfile() == null ? "" : sha256(packages.lockfile());
+ try {
+ if (Files.isRegularFile(stamp) && Files.readString(stamp).equals(lockfile)) return;
+ Process install = spawn(packages.install());
+ if (install.waitFor() != 0) throw new IllegalStateException(String.join(" ", packages.install()) + " failed with " + install.exitValue());
+ Files.createDirectories(stamp.getParent());
+ Files.writeString(stamp, lockfile);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted installing the frontend's dependencies", e);
+ }
+ }
+
+ private void start() {
+ if (answers()) throw new IllegalStateException("Port " + port + " is taken: stop what uses it or set ViteExtension.devPort(...).");
+ process = spawn(packages.run("dev", "--host", "127.0.0.1", "--port", String.valueOf(port), "--strictPort"));
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
+ while (!answers()) {
+ if (!process.isAlive()) throw new IllegalStateException("Vite exited with " + process.exitValue() + " before serving on port " + port + " (its output is logged above).");
+ if (System.nanoTime() > deadline) throw new IllegalStateException("Vite did not answer on port " + port + " within 30 seconds.");
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted waiting for Vite", e);
+ }
+ }
+ log.info("Vite is serving http://127.0.0.1:{}", port);
+ }
+
+ /** npm, pnpm and friends are wrappers: their children have to go as well, gracefully first. */
+ private void stop() {
+ if (process == null) return;
+ List tree = process.descendants().toList();
+ tree.forEach(ProcessHandle::destroy);
+ process.destroy();
+ try {
+ process.onExit().get(5, TimeUnit.SECONDS);
+ } catch (Exception gone) {
+ // Forced below.
+ }
+ tree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
+ if (process.isAlive()) process.destroyForcibly();
+ process = null;
+ }
+
+ /** Starts {@code command} in the project, its output logged line by line. */
+ private Process spawn(List command) {
+ Process started;
+ try {
+ started = new ProcessBuilder(command).directory(root.toFile()).redirectErrorStream(true).start();
+ } catch (IOException e) {
+ throw new IllegalStateException("Cannot run " + command.getFirst() + ": is it installed and on the PATH?", e);
+ }
+ Thread.ofVirtual().start(() -> {
+ try (BufferedReader out = new BufferedReader(new InputStreamReader(started.getInputStream(), StandardCharsets.UTF_8))) {
+ for (String line; (line = out.readLine()) != null; ) log.info("[vite] {}", line);
+ } catch (IOException closed) {
+ // The process ended.
+ }
+ });
+ return started;
+ }
+
+ private boolean answers() {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ private FileTime modified() {
+ try {
+ return packages.lockfile() == null ? FileTime.fromMillis(0) : Files.getLastModifiedTime(packages.lockfile());
+ } catch (IOException e) {
+ return FileTime.fromMillis(0);
+ }
+ }
+
+ private static String sha256(Path file) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java
new file mode 100644
index 0000000..7ab11b5
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java
@@ -0,0 +1,63 @@
+package dev.relism.flash.ext.vite;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/** The package manager a frontend uses, told apart by its lockfile. Shared with the Maven plugin. */
+public enum PackageManager {
+ PNPM("pnpm-lock.yaml", "install", "--frozen-lockfile"),
+ YARN("yarn.lock", "install", "--frozen-lockfile"),
+ BUN("bun.lock", "install", "--frozen-lockfile"),
+ NPM("package-lock.json", "ci");
+
+ private static final boolean WINDOWS = System.getProperty("os.name", "").startsWith("Windows");
+
+ private final String lockfile;
+ private final String[] install;
+
+ PackageManager(String lockfile, String... install) {
+ this.lockfile = lockfile;
+ this.install = install;
+ }
+
+ /** A package manager and the lockfile it was found by, {@code null} when there is none. */
+ public record Found(PackageManager manager, Path lockfile) {
+
+ /** Installs exactly what the lockfile pins; without one, npm resolves afresh. */
+ public List install() {
+ if (lockfile == null) return List.of(manager.binary(), "install");
+ List command = new ArrayList<>(List.of(manager.binary()));
+ command.addAll(List.of(manager.install));
+ return command;
+ }
+
+ /** Runs a {@code package.json} script, passing {@code args} through to it. */
+ public List run(String script, String... args) {
+ List command = new ArrayList<>(List.of(manager.binary(), "run", script));
+ if (manager == NPM && args.length > 0) command.add("--");
+ command.addAll(List.of(args));
+ return command;
+ }
+ }
+
+ /**
+ * The nearest lockfile at or above {@code root}, so a project inside a workspace finds the
+ * workspace's. None at all is npm without a lockfile.
+ */
+ public static Found of(Path root) {
+ for (Path dir = root.toAbsolutePath().normalize(); dir != null; dir = dir.getParent()) {
+ for (PackageManager manager : values()) {
+ Path lockfile = dir.resolve(manager.lockfile);
+ if (Files.isRegularFile(lockfile)) return new Found(manager, lockfile);
+ }
+ }
+ return new Found(NPM, null);
+ }
+
+ private String binary() {
+ String name = name().toLowerCase();
+ return WINDOWS ? name + ".cmd" : name;
+ }
+}
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
new file mode 100644
index 0000000..65f4aa1
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java
@@ -0,0 +1,62 @@
+package dev.relism.flash.ext.vite;
+
+import dev.relism.flash.Flash;
+import dev.relism.flash.extension.FlashContext;
+import dev.relism.flash.extension.FlashExtension;
+import dev.relism.flash.extension.FlashRegistrar;
+
+import java.nio.file.Path;
+
+/**
+ * A Vite frontend for a Flash app. In DEV it runs Vite's dev server beside the app, which the
+ * browser talks to and which proxies the backend's paths to Flash. Otherwise it serves the build
+ * that {@code flash-ext-vite-maven-plugin} packaged into the jar, as a single-page app.
+ */
+public final class ViteExtension implements FlashExtension {
+
+ /** Where the build lives on the classpath: the Maven plugin puts it there, production reads it from there. */
+ public static final String CLASSPATH = "flash-vite";
+
+ private Path root = Path.of("web");
+ private int devPort = 5173;
+ private String basePath = "/";
+
+ /** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */
+ public ViteExtension root(Path root) {
+ this.root = root;
+ return this;
+ }
+
+ /** Vite's port in DEV, 5173 by default. */
+ public ViteExtension devPort(int devPort) {
+ this.devPort = devPort;
+ return this;
+ }
+
+ /** Where the frontend is served from, {@code /} by default; Vite's {@code base} has to match it. */
+ public ViteExtension basePath(String basePath) {
+ String trimmed = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
+ this.basePath = trimmed.isEmpty() ? "/" : trimmed.startsWith("/") ? trimmed : "/" + trimmed;
+ 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);
+ String everything = "/".equals(basePath) ? "/**" : basePath + "/**";
+ ctx.onReady(() -> {
+ app.get(everything, (req, res) -> {
+ assets.serve(req, res);
+ return null;
+ });
+ app.head(everything, (req, res) -> {
+ assets.serve(req, res);
+ return null;
+ });
+ });
+ }
+}
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
new file mode 100644
index 0000000..a1a6720
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java
@@ -0,0 +1,56 @@
+package dev.relism.flash.ext.vite;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AssetsTest {
+
+ @TempDir
+ Path dir;
+
+ /** What production runs from: the build inside a jar, not a directory. */
+ @Test
+ void theBuildIsReadFromInsideAJar() throws IOException {
+ Path jar = dir.resolve("app.jar");
+ try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) {
+ for (String name : new String[]{"flash-vite/index.html", "flash-vite/assets/a-12345678.css"}) {
+ out.putNextEntry(new JarEntry(name));
+ out.write("x".getBytes());
+ }
+ }
+ 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());
+ }
+ }
+
+ @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"));
+ }
+ }
+
+ @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"));
+ assertFalse(Assets.acceptsGzip(null));
+ }
+}
diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java
new file mode 100644
index 0000000..5017b81
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java
@@ -0,0 +1,56 @@
+package dev.relism.flash.ext.vite;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+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 real child process: a package.json whose dev script stands in for Vite. Needs npm on the PATH. */
+class DevServerTest {
+
+ @TempDir
+ Path project;
+
+ @Test
+ void startsTheDevScriptWaitsForItAndTakesItDownOnClose() throws Exception {
+ assumeTrue(onPath("npm"), "npm is not on the PATH");
+ int port;
+ try (ServerSocket free = new ServerSocket(0)) {
+ port = free.getLocalPort();
+ }
+ Files.writeString(project.resolve("package.json"), """
+ {"name":"t","private":true,"scripts":{"dev":"node -e \\"require('http').createServer((q,s)=>s.end('ok')).listen(+process.argv[process.argv.indexOf('--port')+1],'127.0.0.1')\\" --"}}
+ """);
+ DevServer server = new DevServer(project, port);
+ assertTrue(answers(port));
+ assertTrue(Files.isRegularFile(project.resolve("node_modules/.flash-vite")));
+ server.close();
+ assertFalse(answers(port));
+ }
+
+ private static boolean answers(int port) {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
+ return true;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ private static boolean onPath(String command) {
+ try {
+ return new ProcessBuilder(command, "--version").start().waitFor() == 0;
+ } catch (IOException | InterruptedException e) {
+ return false;
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java
new file mode 100644
index 0000000..243efcb
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java
@@ -0,0 +1,41 @@
+package dev.relism.flash.ext.vite;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PackageManagerTest {
+
+ @TempDir
+ Path workspace;
+
+ /** A project inside a workspace installs from the workspace's lockfile; its own one wins when it has one. */
+ @Test
+ void theNearestLockfileDecides() throws IOException {
+ Path project = Files.createDirectories(workspace.resolve("apps/web"));
+ Files.writeString(workspace.resolve("pnpm-lock.yaml"), "");
+ PackageManager.Found found = PackageManager.of(project);
+ assertEquals(PackageManager.PNPM, found.manager());
+ assertTrue(found.install().getFirst().startsWith("pnpm"));
+ assertEquals(List.of("install", "--frozen-lockfile"), found.install().subList(1, 3));
+
+ Files.writeString(project.resolve("package-lock.json"), "");
+ assertEquals(PackageManager.NPM, PackageManager.of(project).manager());
+ assertEquals("ci", PackageManager.of(project).install().get(1));
+ }
+
+ @Test
+ void npmNeedsTheSeparatorBeforeScriptArguments() {
+ PackageManager.Found npm = new PackageManager.Found(PackageManager.NPM, null);
+ assertEquals(List.of("run", "dev", "--", "--port", "1"), npm.run("dev", "--port", "1").subList(1, 6));
+ assertEquals("install", npm.install().get(1));
+ assertEquals(List.of("run", "dev", "--port", "1"), new PackageManager.Found(PackageManager.PNPM, null).run("dev", "--port", "1").subList(1, 5));
+ }
+}
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
new file mode 100644
index 0000000..2c3a3e5
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java
@@ -0,0 +1,70 @@
+package dev.relism.flash.ext.vite;
+
+import dev.relism.flash.testing.FlashTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/** The packaged build (src/test/resources/flash-vite) served by a real server, outside DEV. */
+class ViteExtensionTest {
+
+ private static final String JS = "/assets/app-AbCd1234.js";
+
+ @RegisterExtension
+ static final FlashTest app = FlashTest.of(flash -> flash.get("/api/before", (req, res) -> "before")
+ .install(new ViteExtension()).get("/api/ping", (req, res) -> "pong"));
+
+ @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)
+ .expectHeader("Cache-Control", "public, max-age=31536000, immutable")
+ .expectHeader("Content-Type", "text/javascript; charset=utf-8")
+ .expectBodyContains("built");
+ app.get("/favicon.svg").expectStatus(200).expectHeader("Cache-Control", "no-cache");
+ }
+
+ @Test
+ void gzipIsServedOnlyWhenAccepted() {
+ app.request().header("Accept-Encoding", "br, gzip").get(JS).expectHeader("Content-Encoding", "gzip").expectHeader("Vary", "Accept-Encoding");
+ assertNull(app.request().header("Accept-Encoding", "gzip;q=0").get(JS).header("Content-Encoding"));
+ assertNull(app.get(JS).header("Content-Encoding"));
+ }
+
+ @Test
+ void clientRoutesGetTheIndexMissingFilesA404() {
+ app.get("/content/2").expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa");
+ app.get("/assets/missing.js").expectStatus(404);
+ }
+
+ @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");
+ }
+
+ @Test
+ void headDescribesTheBodyWithoutSendingIt() {
+ String length = app.get(JS).header("Content-Length");
+ var head = app.request().head(JS).expectStatus(200).expectHeader("Content-Length", length);
+ assertEquals("", head.body());
+ }
+
+ @Test
+ void aBasePathPrefixesEveryRoute() {
+ nested.get("/app" + JS).expectStatus(200);
+ nested.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 b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js
new file mode 100644
index 0000000..ac2f8d1
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js
@@ -0,0 +1,100 @@
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
+console.log("built");
diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg
new file mode 100644
index 0000000..0c7be4e
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html
new file mode 100644
index 0000000..6d37619
--- /dev/null
+++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html
@@ -0,0 +1 @@
+spa
diff --git a/flash-extensions/flash-ext-web-bundler/docs/README.md b/flash-extensions/flash-ext-web-bundler/docs/README.md
deleted file mode 100644
index 9e3db83..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/README.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# flash-web-bundler
-
-`flash-web-bundler` integrates frontend tooling lifecycle into Flash with explicit, policy-driven behavior.
-
-## Quick Start
-
-```java
-FlashApp.create(8080)
- .install(new WebBundlerExtension(
- WebBundlerConfig.builder()
- .webRoot(Path.of("web"))
- .basePath("/")
- .build()
- ))
- .start();
-```
-
-## Operating Model
-
-- Dev: orchestrates frontend process lifecycle and health checks.
-- Prod: serves prebuilt assets from `assetsSource` with ETag/cache/compression support.
-- Route precedence: backend first, SPA fallback second (`GET`/`HEAD`).
-
-See also: `asset-sources.md`.
diff --git a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md b/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md
deleted file mode 100644
index a0a82bf..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Asset Sources
-
-`flash-web-bundler` uses explicit source objects for production assets.
-
-Supported sources:
-
-- `FilesystemAssetsSource.of(Path)`
-- `ClasspathAssetsSource.of(String rootPrefix)`
-
-Builder shortcuts:
-
-- `.assetsFromFilesystem(Path.of("dist"))`
-- `.assetsFromClasspath("web/dist")`
-
-Classpath source requires `asset-manifest.json` generated at build time.
-The developer does not maintain this file manually — generate it with `WebBundlerBuild`
-(`dev.relism.flash.ext.webbundler.WebBundlerBuild`), which scans a prebuilt directory (a Vite
-`dist/` or a `STATIC` asset folder) and writes the manifest into it, ready to be picked up as a
-classpath resource once that directory lands under `target/classes`. See `build-time.md`.
-
-`WebBundlerBuild` reuses the exact same etag/mimeType/immutable computation `FilesystemAssetsSource`
-uses at runtime, so a file served from disk in dev and the same file served from the classpath in
-prod get identical cache semantics.
-
-Production startup is fail-fast if:
-
-- manifest is missing
-- manifest is invalid
-- manifest points to missing resources
diff --git a/flash-extensions/flash-ext-web-bundler/docs/build-time.md b/flash-extensions/flash-ext-web-bundler/docs/build-time.md
deleted file mode 100644
index 8276599..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/build-time.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Build-Time Manifest Generation
-
-`WebBundlerBuild` turns an already-built directory into the `asset-manifest.json` that
-`ClasspathAssetsSource` needs (see `asset-sources.md`). It does not run a frontend build itself —
-it only scans a directory that already contains the final files:
-
-- `VITE`: point it at whatever `dist/` the existing frontend build tooling already produces.
-- `STATIC`: point it directly at the static asset folder — there's no separate build step.
-
-It's meant to run once per build, from the consumer project's own build, not from the running
-application (`ClasspathAssetsSource` is explicitly unsupported in DEV — see `dev-lifecycle.md`).
-
-## Wiring it into a Maven build
-
-No dedicated Flash5 Maven plugin — `WebBundlerBuild` is a plain class with a `main`, invoked via
-the standard `exec-maven-plugin`, bound to run before the resources are packaged:
-
-```xml
-
- org.codehaus.mojo
- exec-maven-plugin
-
-
- web-bundler-manifest
- process-classes
- java
-
- dev.relism.flash.ext.webbundler.WebBundlerBuild
-
- ${project.build.outputDirectory}/web/dist
-
-
-
-
-
-```
-
-This assumes the built frontend (`web/dist/`, or a static folder) is already copied under
-`target/classes/web/dist` by that point — e.g. via `maven-resources-plugin`'s `copy-resources`
-goal, or by running the frontend build with an output directory that points there directly. Once
-the manifest is written alongside those files, they're just classpath resources: a plain `mvn
-package` (or `maven-shade-plugin` for a fat jar) picks them up with no further configuration, and
-the app can then be configured with `.assetsFromClasspath("web/dist")`.
diff --git a/flash-extensions/flash-ext-web-bundler/docs/configuration.md b/flash-extensions/flash-ext-web-bundler/docs/configuration.md
deleted file mode 100644
index 1c8b8f6..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/configuration.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Configuration
-
-`WebBundlerConfig` is immutable and built with `WebBundlerConfig.builder()`.
-
-Key fields:
-
-- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT`
-- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
-- `frontendType`: `VITE`, `STATIC` — see `frontend-selection.md`
-- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
-- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
-- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
-- `commandSafetyMode`: `WARN`, `BLOCK`, `ALLOW`
-- `webRoot`, `assetsSource`, `basePath`, `devHost`, `devPort`, `watchList`
-
-Asset source is explicit and object-based:
-
-- `assetsFromFilesystem(Path.of("dist"))`
-- `assetsFromClasspath("web/dist")`
-
-Or by direct source object:
-
-- `assetsSource(FilesystemAssetsSource.of(Path.of("dist")))`
-- `assetsSource(ClasspathAssetsSource.of("web/dist"))`
-
-Validation is fail-fast:
-
-- invalid `devPort` (only when `frontendType` requires orchestration — skipped for `STATIC`)
-- blank/invalid watch entries (same — skipped for `STATIC`)
-- invalid `basePath`
-
-`frontendType(...)` has side effects on other defaults, same pattern as `packageManager(...)`
-resetting `watchList`: it also resets `operationMode` (`MANAGED` for `STATIC`, `ORCHESTRATE_ONLY`
-otherwise) and `assetsSource` (`webRoot` itself for `STATIC`, `webRoot/dist` otherwise). Call
-`.frontendType(...)` before any explicit `.operationMode(...)`/`.assetsSource(...)`/`.assetsFrom*(...)`
-override, or the later call wins.
diff --git a/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md b/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md
deleted file mode 100644
index c86aebf..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Dev Lifecycle
-
-Boot flow in dev mode:
-
-1. Resolve runtime mode.
-2. Validate configuration and command safety.
-3. Optionally install dependencies (hash-based lockfile cache).
-4. Start dev server process.
-5. Run healthcheck.
-6. Start watchlist loop and restart dev process when tracked files change.
-
-Failure conditions are fail-fast:
-
-- occupied dev port
-- command launch failure
-- healthcheck timeout
diff --git a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md b/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md
deleted file mode 100644
index e72fdb4..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Frontend Selection
-
-Frontend integration is explicit through `frontendType`.
-
-- No heuristic detection in v1.
-- Deterministic mapping: `FrontendType -> FrontendStrategy`.
-- Built-in strategies: `VITE`, `STATIC`.
-
-## VITE
-
-Orchestrates a dev server process in DEV, serves a prebuilt directory in PROD. See `dev-lifecycle.md`.
-
-## STATIC
-
-For files served as-is — no dev server, no package manager, no build step, no watch loop.
-`STATIC` never orchestrates, in DEV or PROD: it always loads `assetsSource` directly and serves it,
-the same code path `VITE` only uses in PROD. Editing a file during a running dev session requires a
-restart to be picked up (assets are preloaded once, same as `VITE`'s prod serving — no hot reload).
-
-Setting `.frontendType(FrontendType.STATIC)` also switches two other defaults (see `configuration.md`):
-`operationMode` becomes `MANAGED` and `assetsSource` defaults to the `webRoot` itself instead of a
-`dist` subdirectory — a minimal STATIC config is just:
-
-```java
-WebBundlerConfig.builder()
- .frontendType(FrontendType.STATIC)
- .webRoot(Path.of("public"))
- .build()
-```
-
-Extension points:
-
-- register custom `FrontendStrategy` implementations in the resolver.
-- override default commands per config.
diff --git a/flash-extensions/flash-ext-web-bundler/docs/modes.md b/flash-extensions/flash-ext-web-bundler/docs/modes.md
deleted file mode 100644
index 11723b1..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/modes.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Modes
-
-## Runtime Mode
-
-- `PROD`: production static serving only.
-- `AUTODETECT`: uses `Flash.DEV`.
-- `ENV`: uses `FLASH_WEB_BUNDLER_MODE` (`dev` => dev mode, else prod).
-
-## Operation Mode
-
-- `ORCHESTRATE_ONLY`: only orchestrates dev tooling.
-- `MANAGED`: enables production serving + SPA fallback routes.
-
-`FrontendType.STATIC` defaults `operationMode` to `MANAGED` (see `frontend-selection.md`) — `STATIC`
-has no dev tooling to orchestrate, so `ORCHESTRATE_ONLY` would make the extension a no-op for it.
-
-## Orchestration
-
-Whether DEV mode spawns a dev-server process at all is a separate axis from Runtime Mode: it also
-depends on `frontendType`. `VITE` orchestrates in DEV; `STATIC` never does, in DEV or PROD — it
-always loads and serves `assetsSource` directly, the same path `VITE` only takes in PROD.
diff --git a/flash-extensions/flash-ext-web-bundler/docs/package-managers.md b/flash-extensions/flash-ext-web-bundler/docs/package-managers.md
deleted file mode 100644
index de3a7e6..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/package-managers.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Package Managers
-
-Package manager selection is explicit via `packageManager`.
-
-- `NPM` -> `package-lock.json`
-- `PNPM` -> `pnpm-lock.yaml`
-- `YARN` -> `yarn.lock`
-- `BUN` -> `bun.lockb`
-
-Install policy:
-
-- `AUTO_IF_LOCK_HASH_CHANGED`: install only when lock hash changes.
-- `NEVER`: never auto-install.
-
-Custom commands can override defaults via:
-
-- `installCommand`
-- `devCommand`
-- `buildCommand`
diff --git a/flash-extensions/flash-ext-web-bundler/docs/performance.md b/flash-extensions/flash-ext-web-bundler/docs/performance.md
deleted file mode 100644
index 84ce194..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/performance.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Performance Notes
-
-Current v1 optimizations:
-
-- startup preloading of static assets
-- precomputed ETags and MIME lookup
-- compressed payload selection based on `Accept-Encoding`
-- lockfile-hash install gate to avoid redundant installs
-
-Future performance work:
-
-- streaming/sliced file serving for large assets
-- benchmark suite with p50/p95/p99 latency and allocation profiling
diff --git a/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md b/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md
deleted file mode 100644
index 71db8bc..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Production Serving
-
-In production mode, `flash-web-bundler` serves only prebuilt assets.
-
-Behavior:
-
-- preloads files from `assetsSource` (filesystem or classpath)
-- resolves MIME by extension
-- supports precompressed siblings (`.br`, `.gz`)
-- sets `Cache-Control` and `ETag`
-- responds `304 Not Modified` when `If-None-Match` matches
-
-Classpath mode details:
-
-- reads entries from `asset-manifest.json` in classpath root
-- validates referenced resources at startup
-- fails fast in production if manifest or resources are inconsistent
diff --git a/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md b/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md
deleted file mode 100644
index 943762e..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Routing Fallback
-
-Fallback policy:
-
-- backend routes always have precedence
-- fallback route is wildcard under configured `basePath`
-- fallback applies to `GET` and `HEAD` only
-- if no static asset matches, serve SPA `indexFile`
-
-Namespace collisions should be avoided at app design level (for example, keep API under `/api`).
diff --git a/flash-extensions/flash-ext-web-bundler/docs/security-policies.md b/flash-extensions/flash-ext-web-bundler/docs/security-policies.md
deleted file mode 100644
index 5a06ccf..0000000
--- a/flash-extensions/flash-ext-web-bundler/docs/security-policies.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Security Policies
-
-Command safety is enforced before process execution.
-
-Modes:
-
-- `WARN`: allow unknown binary and log warning.
-- `BLOCK`: reject unknown binary.
-- `ALLOW`: skip safe-registry checks.
-
-Safe registry defaults include common package-manager executables and is configurable.
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java
deleted file mode 100644
index d5dd346..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.Map;
-
-public final class AssetCatalog {
- private final Map byRoutePath;
- private final AssetEntry index;
-
- AssetCatalog(Map byRoutePath, AssetEntry index) {
- this.byRoutePath = Map.copyOf(byRoutePath);
- this.index = index;
- }
-
- AssetEntry find(String routePath) {
- return byRoutePath.get(routePath);
- }
-
- AssetEntry index() {
- return index;
- }
-
- public AssetEntry asset(String routePath) {
- return find(routePath);
- }
-
- public AssetEntry indexAsset() {
- return index;
- }
-
- public int size() {
- return byRoutePath.size();
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java
deleted file mode 100644
index 088198b..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Walks a directory and computes the same etag/mimeType/immutable metadata a served asset needs.
- * Shared by {@link FilesystemAssetsSource} (runtime, dev/filesystem prod) and {@link WebBundlerBuild}
- * (build-time classpath manifest) so both agree on cache semantics for the same file.
- */
-final class AssetDirectoryScanner {
- private AssetDirectoryScanner() {
- }
-
- record ScannedAsset(String canonicalPath, byte[] raw, byte[] br, byte[] gz, String etag, String mimeType, boolean immutable) {
- }
-
- static List scan(Path root) {
- Map builders = new HashMap<>();
- try (var walk = Files.walk(root)) {
- walk.filter(Files::isRegularFile).forEach(file -> {
- String rel = "/" + root.relativize(file).toString().replace('\\', '/');
- String canonical = AssetIo.stripBrGzSuffix(rel);
- Builder b = builders.computeIfAbsent(canonical, Builder::new);
- byte[] bytes = AssetIo.read(file);
- if (rel.endsWith(".br")) b.br = bytes;
- else if (rel.endsWith(".gz")) b.gz = bytes;
- else b.raw = bytes;
- });
- } catch (IOException e) {
- throw new IllegalStateException("Failed to scan assets from " + root, e);
- }
-
- List result = new ArrayList<>();
- for (Builder b : builders.values()) {
- if (b.raw == null) continue;
- String etag = AssetIo.quotedSha1(b.raw);
- String mime = MimeTypes.byPath(b.canonicalPath);
- boolean immutable = AssetIo.isFingerprinted(b.canonicalPath);
- result.add(new ScannedAsset(b.canonicalPath, b.raw, b.br, b.gz, etag, mime, immutable));
- }
- return result;
- }
-
- private static final class Builder {
- private final String canonicalPath;
- private byte[] raw;
- private byte[] br;
- private byte[] gz;
-
- private Builder(String canonicalPath) {
- this.canonicalPath = canonicalPath;
- }
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java
deleted file mode 100644
index 46bde2b..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public record AssetEntry(
- byte[] raw,
- byte[] br,
- byte[] gz,
- String etag,
- String mimeType,
- boolean immutable
-) {
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java
deleted file mode 100644
index 3cbaeaa..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java
+++ /dev/null
@@ -1,93 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-final class AssetIo {
- private AssetIo() {
- }
-
- static byte[] read(Path path) {
- try {
- return Files.readAllBytes(path);
- } catch (IOException e) {
- throw new IllegalStateException("Failed reading asset " + path, e);
- }
- }
-
- static byte[] readClasspath(String path) {
- try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) {
- if (in == null) throw new IllegalStateException("Missing classpath resource: " + path);
- return in.readAllBytes();
- } catch (IOException e) {
- throw new IllegalStateException("Failed reading classpath resource: " + path, e);
- }
- }
-
- static byte[] readClasspathOrNull(String path) {
- try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) {
- if (in == null) return null;
- return in.readAllBytes();
- } catch (IOException e) {
- throw new IllegalStateException("Failed reading classpath resource: " + path, e);
- }
- }
-
- static String quotedSha1(byte[] data) {
- return "\"" + sha1Hex(data) + "\"";
- }
-
- private static String sha1Hex(byte[] data) {
- try {
- MessageDigest md = MessageDigest.getInstance("SHA-1");
- byte[] hash = md.digest(data);
- StringBuilder out = new StringBuilder(hash.length * 2);
- for (byte b : hash) {
- out.append(String.format("%02x", b));
- }
- return out.toString();
- } catch (NoSuchAlgorithmException e) {
- throw new IllegalStateException("SHA-1 unavailable", e);
- }
- }
-
- static String stripLeadingSlash(String path) {
- if (path == null || path.isBlank()) return path;
- return path.startsWith("/") ? path.substring(1) : path;
- }
-
- static String normalizePath(String path) {
- if (path == null || path.isBlank()) return "/";
- String p = path.replace('\\', '/');
- if (!p.startsWith("/")) p = "/" + p;
- return p;
- }
-
- static String stripBrGzSuffix(String path) {
- if (path.endsWith(".br")) return path.substring(0, path.length() - 3);
- if (path.endsWith(".gz")) return path.substring(0, path.length() - 3);
- return path;
- }
-
- static boolean isFingerprinted(String path) {
- String file = path;
- int slash = file.lastIndexOf('/');
- if (slash >= 0) file = file.substring(slash + 1);
- int dot = file.lastIndexOf('.');
- if (dot <= 0) return false;
- int prevDot = file.lastIndexOf('.', dot - 1);
- if (prevDot <= 0) return false;
- String token = file.substring(prevDot + 1, dot);
- if (token.length() < 8) return false;
- for (int i = 0; i < token.length(); i++) {
- char c = token.charAt(i);
- boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
- if (!hex) return false;
- }
- return true;
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java
deleted file mode 100644
index eb88e56..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.nio.file.Path;
-
-public record AssetLoadRequest(String basePath, String indexFile, RuntimeEnvironment environment, Path webRoot) {
- public AssetLoadRequest {
- if (basePath == null || basePath.isBlank()) throw new IllegalArgumentException("basePath is required");
- if (indexFile == null || indexFile.isBlank()) throw new IllegalArgumentException("indexFile is required");
- if (environment == null) throw new IllegalArgumentException("environment is required");
- if (webRoot == null) throw new IllegalArgumentException("webRoot is required");
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java
deleted file mode 100644
index 4e8d8a3..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-record AssetMetadata(
- String routePath,
- String logicalPath,
- String mimeType,
- String etag,
- boolean immutable
-) {
- AssetMetadata {
- if (routePath == null || routePath.isBlank()) throw new IllegalArgumentException("routePath is required");
- if (logicalPath == null || logicalPath.isBlank()) throw new IllegalArgumentException("logicalPath is required");
- if (mimeType == null || mimeType.isBlank()) throw new IllegalArgumentException("mimeType is required");
- if (etag == null || etag.isBlank()) throw new IllegalArgumentException("etag is required");
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java
deleted file mode 100644
index 7761eac..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-final class AssetPaths {
- private AssetPaths() {
- }
-
- static String joinBase(String basePath, String routePath) {
- String base = "/".equals(basePath) ? "" : basePath;
- String route = AssetIo.normalizePath(routePath);
- return base + route;
- }
-
- static String normalizePrefix(String prefix) {
- if (prefix == null || prefix.isBlank()) return "";
- String p = prefix.replace('\\', '/');
- if (p.startsWith("/")) p = p.substring(1);
- while (p.endsWith("/")) p = p.substring(0, p.length() - 1);
- return p;
- }
-
- static String classpathJoin(String prefix, String relative) {
- String p = normalizePrefix(prefix);
- String r = AssetIo.stripLeadingSlash(relative).replace('\\', '/');
- return p.isEmpty() ? r : p + "/" + r;
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java
deleted file mode 100644
index 4660b1e..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public interface AssetsSource {
- AssetCatalog load(AssetLoadRequest request);
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java
deleted file mode 100644
index 6fe119e..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.nio.file.Path;
-
-public final class AssetsSources {
- private AssetsSources() {
- }
-
- public static AssetsSource filesystem(Path distDir) {
- return FilesystemAssetsSource.of(distDir);
- }
-
- public static AssetsSource classpath(String rootPrefix) {
- return ClasspathAssetsSource.of(rootPrefix);
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java
deleted file mode 100644
index b7b8c0d..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum BasePathEnforcementMode {
- WARN_ONLY,
- STRICT
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java
deleted file mode 100644
index 367e468..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-import java.util.List;
-
-record ClasspathAssetManifest(@JsonProperty("assets") List assets) {
- record Entry(
- @JsonProperty("routePath") String routePath,
- @JsonProperty("resourcePath") String resourcePath,
- @JsonProperty("mimeType") String mimeType,
- @JsonProperty("etag") String etag,
- @JsonProperty("immutable") boolean immutable
- ) {
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java
deleted file mode 100644
index 571cd99..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java
+++ /dev/null
@@ -1,91 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.HashMap;
-import java.util.Map;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-public final class ClasspathAssetsSource implements AssetsSource {
- private static final ObjectMapper JSON = new ObjectMapper();
-
- private final String rootPrefix;
- private final String manifestResource;
-
- private ClasspathAssetsSource(String rootPrefix, String manifestResource) {
- this.rootPrefix = AssetPaths.normalizePrefix(rootPrefix);
- this.manifestResource = AssetIo.stripLeadingSlash(manifestResource);
- }
-
- public static ClasspathAssetsSource of(String rootPrefix) {
- if (rootPrefix == null || rootPrefix.isBlank()) {
- throw new IllegalArgumentException("rootPrefix cannot be blank");
- }
- String normalized = AssetPaths.normalizePrefix(rootPrefix);
- return new ClasspathAssetsSource(normalized, normalized + "/asset-manifest.json");
- }
-
- public static ClasspathAssetsSource of(String rootPrefix, String manifestResource) {
- if (rootPrefix == null || rootPrefix.isBlank()) {
- throw new IllegalArgumentException("rootPrefix cannot be blank");
- }
- if (manifestResource == null || manifestResource.isBlank()) {
- throw new IllegalArgumentException("manifestResource cannot be blank");
- }
- return new ClasspathAssetsSource(rootPrefix, manifestResource);
- }
-
- @Override
- public AssetCatalog load(AssetLoadRequest request) {
- ClasspathAssetManifest manifest = readManifest();
- if (manifest.assets() == null || manifest.assets().isEmpty()) {
- throw new IllegalStateException("Classpath asset manifest has no assets: " + manifestResource);
- }
-
- Map byRoute = new HashMap<>();
- for (ClasspathAssetManifest.Entry entry : manifest.assets()) {
- AssetMetadata meta = toMeta(entry);
- byte[] raw = AssetIo.readClasspath(resourcePath(meta.logicalPath()));
- byte[] br = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".br"));
- byte[] gz = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".gz"));
- String routePath = AssetPaths.joinBase(request.basePath(), meta.routePath());
- byRoute.put(routePath, new AssetEntry(raw, br, gz, meta.etag(), meta.mimeType(), meta.immutable()));
- }
-
- String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
- AssetEntry index = byRoute.get(indexRoute);
- if (index == null) {
- throw new IllegalStateException("Missing SPA fallback file in classpath manifest: " + indexRoute);
- }
- return new AssetCatalog(byRoute, index);
- }
-
- private ClasspathAssetManifest readManifest() {
- try (InputStream in = ClasspathAssetsSource.class.getClassLoader().getResourceAsStream(manifestResource)) {
- if (in == null) throw new IllegalStateException("Missing classpath manifest: " + manifestResource);
- String json = new String(in.readAllBytes(), StandardCharsets.UTF_8);
- return JSON.readValue(json, ClasspathAssetManifest.class);
- } catch (IOException e) {
- throw new IllegalStateException("Invalid classpath manifest: " + manifestResource, e);
- }
- }
-
- private AssetMetadata toMeta(ClasspathAssetManifest.Entry entry) {
- if (entry == null) throw new IllegalStateException("Manifest entry cannot be null");
- String routePath = AssetIo.normalizePath(entry.routePath());
- String logicalPath = entry.resourcePath();
- if (logicalPath == null || logicalPath.isBlank()) {
- throw new IllegalStateException("Manifest entry resourcePath is required");
- }
- return new AssetMetadata(routePath, logicalPath.trim(), entry.mimeType(), entry.etag(), entry.immutable());
- }
-
- private String resourcePath(String logicalPath) {
- if (logicalPath.startsWith("/")) {
- return logicalPath.substring(1);
- }
- return AssetPaths.classpathJoin(rootPrefix, logicalPath);
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java
deleted file mode 100644
index fd1a871..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java
+++ /dev/null
@@ -1,187 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.net.HttpURLConnection;
-import java.net.InetSocketAddress;
-import java.net.Socket;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Path;
-import java.time.Duration;
-import java.util.List;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.TimeUnit;
-
-final class CommandOrchestrator implements AutoCloseable {
- private static final Logger log = LoggerFactory.getLogger(CommandOrchestrator.class);
-
- private final WebBundlerConfig config;
- private final CommandSafetyPolicy safetyPolicy;
- private final ExecutorService logPool = Executors.newCachedThreadPool(daemonThreadFactory("flash-web-bundler-log"));
- private Process devProcess;
-
- CommandOrchestrator(WebBundlerConfig config, CommandSafetyPolicy safetyPolicy) {
- this.config = config;
- this.safetyPolicy = safetyPolicy;
- }
-
- void runBlocking(List command, Path cwd) {
- safetyPolicy.check(command);
- Process process = start(command, cwd);
- try {
- int code = process.waitFor();
- if (code != 0) {
- throw new IllegalStateException("Command failed (" + code + "): " + String.join(" ", command));
- }
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new IllegalStateException("Interrupted while waiting process", e);
- }
- }
-
- void startDevServer(List command, Path cwd) {
- if (devProcess != null && devProcess.isAlive()) return;
- if (isPortOccupied(config.devHost(), config.devPort())) {
- throw new IllegalStateException("Dev server port already occupied: " + config.devHost() + ":" + config.devPort());
- }
- safetyPolicy.check(command);
- devProcess = start(command, cwd);
- }
-
- void healthcheck(String healthPath, Duration timeout) {
- long deadline = System.currentTimeMillis() + timeout.toMillis();
- String url = "http://" + config.devHost() + ":" + config.devPort() + healthPath;
- while (System.currentTimeMillis() < deadline) {
- if (devProcess == null || !devProcess.isAlive()) {
- throw new IllegalStateException("Dev process exited before becoming healthy");
- }
- if (ping(url)) return;
- sleep(200L);
- }
- throw new IllegalStateException("Dev server healthcheck timeout for " + url);
- }
-
- void restartDevServer(List command, Path cwd) {
- stopDevServer();
- startDevServer(command, cwd);
- }
-
- void stopDevServer() {
- if (devProcess == null) return;
- destroyProcessTree(devProcess, 5);
- devProcess = null;
- }
-
- @Override
- public void close() {
- stopDevServer();
- logPool.shutdownNow();
- }
-
- private Process start(List command, Path cwd) {
- ProcessBuilder pb = new ProcessBuilder(command);
- pb.directory(cwd.toFile());
- try {
- Process process = pb.start();
- attachLogs(process, command.get(0));
- return process;
- } catch (IOException e) {
- throw new IllegalStateException("Failed to launch process: " + String.join(" ", command), e);
- }
- }
-
- private void attachLogs(Process process, String name) {
- if (config.loggingMode() == LoggingMode.QUIET) return;
- boolean merge = config.loggingMode() == LoggingMode.MERGED || config.loggingMode() == LoggingMode.VERBOSE;
- if (merge) {
- logPool.submit(() -> readStream(process.getInputStream(), "[" + name + "] "));
- logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + "] "));
- } else {
- logPool.submit(() -> readStream(process.getInputStream(), "[" + name + ":out] "));
- logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + ":err] "));
- }
- }
-
- private void readStream(java.io.InputStream in, String prefix) {
- try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
- String line;
- while ((line = r.readLine()) != null) {
- if (config.loggingMode() == LoggingMode.VERBOSE) log.info("{}{}", prefix, line);
- else log.debug("{}{}", prefix, line);
- }
- } catch (IOException ignored) {
- }
- }
-
- private static boolean ping(String rawUrl) {
- try {
- HttpURLConnection conn = (HttpURLConnection) new URL(rawUrl).openConnection();
- conn.setConnectTimeout(500);
- conn.setReadTimeout(500);
- conn.setRequestMethod("GET");
- int code = conn.getResponseCode();
- return code >= 200 && code < 500;
- } catch (IOException e) {
- return false;
- }
- }
-
- private static boolean isPortOccupied(String host, int port) {
- try (Socket socket = new Socket()) {
- socket.connect(new InetSocketAddress(host, port), 250);
- return true;
- } catch (IOException e) {
- return false;
- }
- }
-
- private static void sleep(long millis) {
- try {
- Thread.sleep(millis);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
-
- private static ThreadFactory daemonThreadFactory(String namePrefix) {
- return runnable -> {
- Thread thread = new Thread(runnable, namePrefix);
- thread.setDaemon(true);
- return thread;
- };
- }
-
- private static void destroyProcessTree(Process process, int gracefulSeconds) {
- ProcessHandle root = process.toHandle();
- List descendants = root.descendants().toList();
-
- // First pass: graceful termination for wrapper + children.
- for (ProcessHandle child : descendants) child.destroy();
- root.destroy();
-
- waitForExit(root, gracefulSeconds);
-
- // Second pass: force-kill anything still alive (important on Windows cmd wrappers).
- for (ProcessHandle child : descendants) {
- if (child.isAlive()) child.destroyForcibly();
- }
- if (root.isAlive()) root.destroyForcibly();
-
- waitForExit(root, 2);
- }
-
- private static void waitForExit(ProcessHandle handle, int timeoutSeconds) {
- try {
- handle.onExit().get(timeoutSeconds, TimeUnit.SECONDS);
- } catch (Exception ignored) {
- // Best-effort shutdown; callers handle remaining state.
- }
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java
deleted file mode 100644
index 61882bd..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum CommandSafetyMode {
- WARN,
- BLOCK,
- ALLOW
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java
deleted file mode 100644
index 9169101..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.List;
-
-final class CommandSafetyPolicy {
- private static final Logger log = LoggerFactory.getLogger(CommandSafetyPolicy.class);
-
- private final WebBundlerConfig config;
-
- CommandSafetyPolicy(WebBundlerConfig config) {
- this.config = config;
- }
-
- void check(List command) {
- if (command.isEmpty()) throw new IllegalArgumentException("Command cannot be empty");
- String binary = command.get(0);
- boolean safe = config.safeCommandRegistry().contains(binary);
- if (safe || config.commandSafetyMode() == CommandSafetyMode.ALLOW) return;
- if (config.commandSafetyMode() == CommandSafetyMode.BLOCK) {
- throw new IllegalStateException("Blocked unsafe command: " + String.join(" ", command));
- }
- log.warn("Executing command outside safe registry: {}", String.join(" ", command));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java
deleted file mode 100644
index 8f1a11d..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.Arrays;
-import java.util.List;
-
-final class CommandTokens {
- private CommandTokens() {}
-
- static List split(String command) {
- return Arrays.stream(command.trim().split("\\s+"))
- .filter(s -> !s.isBlank())
- .toList();
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java
deleted file mode 100644
index 769c07b..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.HashMap;
-import java.util.Map;
-
-public final class FilesystemAssetsSource implements AssetsSource {
- private final Path distDir;
-
- private FilesystemAssetsSource(Path distDir) {
- this.distDir = distDir.normalize();
- }
-
- public static FilesystemAssetsSource of(Path distDir) {
- if (distDir == null) throw new IllegalArgumentException("distDir cannot be null");
- return new FilesystemAssetsSource(distDir);
- }
-
- @Override
- public AssetCatalog load(AssetLoadRequest request) {
- Path root = request.webRoot().resolve(distDir).normalize();
- if (!Files.exists(root)) {
- throw new IllegalStateException("distDir does not exist: " + root);
- }
-
- Map byRoute = new HashMap<>();
- for (AssetDirectoryScanner.ScannedAsset asset : AssetDirectoryScanner.scan(root)) {
- String routePath = AssetPaths.joinBase(request.basePath(), asset.canonicalPath());
- byRoute.put(routePath, new AssetEntry(asset.raw(), asset.br(), asset.gz(), asset.etag(), asset.mimeType(), asset.immutable()));
- }
-
- String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
- AssetEntry index = byRoute.get(indexRoute);
- if (index == null) {
- throw new IllegalStateException("Missing SPA fallback file: " + indexRoute + " (from " + root + ")");
- }
- return new AssetCatalog(byRoute, index);
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java
deleted file mode 100644
index 691047b..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.List;
-
-interface FrontendStrategy {
- FrontendType type();
- List devCommand(WebBundlerConfig config, PackageManagerAdapter adapter);
- List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter);
- default String healthcheckPath() {
- return "/";
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java
deleted file mode 100644
index c980410..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum FrontendType {
- VITE(true),
- STATIC(false);
-
- private final boolean requiresOrchestration;
-
- FrontendType(boolean requiresOrchestration) {
- this.requiresOrchestration = requiresOrchestration;
- }
-
- /** Whether this frontend type needs a dev-server process, package manager, and watch loop. */
- boolean requiresOrchestration() {
- return requiresOrchestration;
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java
deleted file mode 100644
index 05b39da..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.EnumMap;
-import java.util.Map;
-
-final class FrontendTypeResolver {
- private final Map strategies = new EnumMap<>(FrontendType.class);
-
- FrontendTypeResolver() {
- register(new ViteFrontendStrategy());
- register(new StaticFrontendStrategy());
- }
-
- void register(FrontendStrategy strategy) {
- strategies.put(strategy.type(), strategy);
- }
-
- FrontendStrategy resolve(FrontendType type) {
- FrontendStrategy strategy = strategies.get(type);
- if (strategy == null) {
- throw new IllegalArgumentException("Unsupported frontend type: " + type);
- }
- return strategy;
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java
deleted file mode 100644
index 9241631..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-final class InstallCache {
- private final Path cacheFile;
-
- InstallCache(Path webRoot) {
- this.cacheFile = webRoot.resolve(".flash-web-bundler/install.hash");
- }
-
- boolean lockChanged(Path lockfile) {
- if (!Files.exists(lockfile)) return true;
- String current = digest(lockfile);
- String previous = readCached();
- return !current.equals(previous);
- }
-
- void writeLockHash(Path lockfile) {
- if (!Files.exists(lockfile)) return;
- try {
- Files.createDirectories(cacheFile.getParent());
- Files.writeString(cacheFile, digest(lockfile), StandardCharsets.UTF_8);
- } catch (IOException e) {
- throw new IllegalStateException("Failed to write install cache hash", e);
- }
- }
-
- private String readCached() {
- if (!Files.exists(cacheFile)) return "";
- try {
- return Files.readString(cacheFile, StandardCharsets.UTF_8).trim();
- } catch (IOException e) {
- return "";
- }
- }
-
- private static String digest(Path file) {
- try {
- MessageDigest md = MessageDigest.getInstance("SHA-256");
- byte[] bytes = Files.readAllBytes(file);
- byte[] hash = md.digest(bytes);
- StringBuilder sb = new StringBuilder(hash.length * 2);
- for (byte b : hash) {
- sb.append(String.format("%02x", b));
- }
- return sb.toString();
- } catch (NoSuchAlgorithmException | IOException e) {
- throw new IllegalStateException("Unable to hash lockfile " + file, e);
- }
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java
deleted file mode 100644
index e271cda..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum InstallPolicy {
- AUTO_IF_LOCK_HASH_CHANGED,
- NEVER
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java
deleted file mode 100644
index 3134e89..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum LoggingMode {
- MERGED,
- SEPARATE,
- QUIET,
- VERBOSE
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java
deleted file mode 100644
index 75db7f2..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.Map;
-
-final class MimeTypes {
- private static final Map EXT = Map.ofEntries(
- Map.entry("html", "text/html"),
- Map.entry("js", "text/javascript"),
- Map.entry("css", "text/css"),
- Map.entry("json", "application/json"),
- Map.entry("svg", "image/svg+xml"),
- Map.entry("png", "image/png"),
- Map.entry("jpg", "image/jpeg"),
- Map.entry("jpeg", "image/jpeg"),
- Map.entry("gif", "image/gif"),
- Map.entry("ico", "image/x-icon"),
- Map.entry("woff2", "font/woff2"),
- Map.entry("ttf", "font/ttf"),
- Map.entry("txt", "text/plain")
- );
-
- private MimeTypes() {}
-
- static String byPath(String path) {
- int idx = path.lastIndexOf('.');
- if (idx < 0 || idx == path.length() - 1) return "application/octet-stream";
- String ext = path.substring(idx + 1).toLowerCase();
- return EXT.getOrDefault(ext, "application/octet-stream");
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java
deleted file mode 100644
index 4f04904..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import dev.relism.flash.Flash;
-
-final class ModeResolver {
- RuntimeEnvironment resolve(WebBundlerConfig config) {
- return switch (config.runtimeMode()) {
- case PROD -> RuntimeEnvironment.PROD;
- case AUTODETECT -> Flash.DEV ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD;
- case ENV -> fromEnvironment(config.envRuntimeVariable());
- };
- }
-
- private RuntimeEnvironment fromEnvironment(String key) {
- String value = System.getenv(key);
- if (value == null || value.isBlank()) return RuntimeEnvironment.PROD;
- return "dev".equalsIgnoreCase(value) ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD;
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java
deleted file mode 100644
index 7d4cfa8..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum OperationMode {
- ORCHESTRATE_ONLY,
- MANAGED
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java
deleted file mode 100644
index 0199419..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum PackageManager {
- NPM("npm", "package-lock.json"),
- PNPM("pnpm", "pnpm-lock.yaml"),
- YARN("yarn", "yarn.lock"),
- BUN("bun", "bun.lockb");
-
- private final String binary;
- private final String lockfileName;
-
- PackageManager(String binary, String lockfileName) {
- this.binary = binary;
- this.lockfileName = lockfileName;
- }
-
- public String binary() {
- return binary;
- }
-
- public String lockfileName() {
- return lockfileName;
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java
deleted file mode 100644
index 53bb798..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.List;
-
-final class PackageManagerAdapter {
- private final WebBundlerConfig config;
- private final boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win");
-
- PackageManagerAdapter(WebBundlerConfig config) {
- this.config = config;
- }
-
- List installCommand() {
- if (config.installCommand() != null) return CommandTokens.split(config.installCommand());
- return switch (config.packageManager()) {
- case NPM -> List.of(bin("npm"), "install");
- case PNPM -> List.of(bin("pnpm"), "install", "--frozen-lockfile");
- case YARN -> List.of(bin("yarn"), "install", "--frozen-lockfile");
- case BUN -> List.of(bin("bun"), "install", "--frozen-lockfile");
- };
- }
-
- List devCommand(String host, int port) {
- return switch (config.packageManager()) {
- case NPM -> List.of(bin("npm"), "run", "dev", "--", "--host", host, "--port", String.valueOf(port));
- case PNPM -> List.of(bin("pnpm"), "dev", "--host", host, "--port", String.valueOf(port));
- case YARN -> List.of(bin("yarn"), "dev", "--host", host, "--port", String.valueOf(port));
- case BUN -> List.of(bin("bun"), "run", "dev", "--host", host, "--port", String.valueOf(port));
- };
- }
-
- List buildCommand() {
- return switch (config.packageManager()) {
- case NPM -> List.of(bin("npm"), "run", "build");
- case PNPM -> List.of(bin("pnpm"), "build");
- case YARN -> List.of(bin("yarn"), "build");
- case BUN -> List.of(bin("bun"), "run", "build");
- };
- }
-
- private String bin(String name) {
- return windows ? name + ".cmd" : name;
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java
deleted file mode 100644
index 7050be5..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-enum RuntimeEnvironment {
- DEV,
- PROD
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java
deleted file mode 100644
index bda8e72..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-public enum RuntimeMode {
- PROD,
- ENV,
- AUTODETECT
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java
deleted file mode 100644
index 0f8f799..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import dev.relism.flash.models.Response;
-
-final class SpaFallbackPolicy {
- private final byte[] html;
-
- SpaFallbackPolicy(AssetCatalog catalog) {
- this.html = catalog.index().raw();
- }
-
- void apply(Response response) {
- response.type("text/html");
- response.header("Cache-Control", "no-cache");
- response.body(html);
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java
deleted file mode 100644
index 105616b..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import dev.relism.flash.http.HttpStatus;
-import dev.relism.flash.models.Request;
-import dev.relism.flash.models.Response;
-
-final class StaticAssetServingPolicy {
- private final AssetCatalog catalog;
-
- StaticAssetServingPolicy(AssetCatalog catalog) {
- this.catalog = catalog;
- }
-
- boolean serve(Request req, Response res) {
- AssetEntry entry = catalog.find(req.path());
- if (entry == null) return false;
- String inm = req.header("If-None-Match");
- if (entry.etag().equals(inm)) {
- res.status(HttpStatus.NOT_MODIFIED);
- return true;
- }
- res.type(entry.mimeType());
- if (entry.immutable()) {
- res.header("Cache-Control", "public, max-age=31536000, immutable");
- } else {
- res.header("Cache-Control", "no-cache");
- }
- res.header("ETag", entry.etag());
- res.header("Vary", "Accept-Encoding");
- if (accepts(req, "br") && entry.br() != null) {
- res.header("Content-Encoding", "br");
- res.body(entry.br());
- } else if (accepts(req, "gzip") && entry.gz() != null) {
- res.header("Content-Encoding", "gzip");
- res.body(entry.gz());
- } else {
- res.body(entry.raw());
- }
- return true;
- }
-
- private static boolean accepts(Request req, String encoding) {
- String value = req.header("Accept-Encoding");
- return value != null && value.contains(encoding);
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java
deleted file mode 100644
index ed34e83..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.List;
-
-/** No dev server, no build step — assets are served as-is. Both methods below are unreachable: call sites are gated by {@link FrontendType#requiresOrchestration()}. */
-final class StaticFrontendStrategy implements FrontendStrategy {
- @Override
- public FrontendType type() {
- return FrontendType.STATIC;
- }
-
- @Override
- public List devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
- throw new UnsupportedOperationException("STATIC frontend type has no dev command");
- }
-
- @Override
- public List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
- throw new UnsupportedOperationException("STATIC frontend type has no build command");
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java
deleted file mode 100644
index e49cbe7..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.util.List;
-
-final class ViteFrontendStrategy implements FrontendStrategy {
- @Override
- public FrontendType type() {
- return FrontendType.VITE;
- }
-
- @Override
- public List devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
- if (config.devCommand() != null) {
- return CommandTokens.split(config.devCommand());
- }
- return adapter.devCommand(config.devHost(), config.devPort());
- }
-
- @Override
- public List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
- if (config.buildCommand() != null) {
- return CommandTokens.split(config.buildCommand());
- }
- return adapter.buildCommand();
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java
deleted file mode 100644
index defd640..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.HashMap;
-import java.util.Map;
-
-final class WatchList {
- private final Path webRoot;
- private final Map lastKnown = new HashMap<>();
-
- WatchList(Path webRoot) {
- this.webRoot = webRoot;
- }
-
- void initialize(Iterable watchEntries) {
- for (String entry : watchEntries) {
- Path file = webRoot.resolve(entry).normalize();
- lastKnown.put(file, lastModified(file));
- }
- }
-
- boolean changed() {
- for (Map.Entry entry : lastKnown.entrySet()) {
- long current = lastModified(entry.getKey());
- if (current != entry.getValue()) {
- entry.setValue(current);
- return true;
- }
- }
- return false;
- }
-
- private static long lastModified(Path path) {
- try {
- if (!Files.exists(path)) return -1L;
- return Files.getLastModifiedTime(path).toMillis();
- } catch (IOException e) {
- return -1L;
- }
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java
deleted file mode 100644
index 678323c..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
-
-/**
- * Build-time counterpart to {@link ClasspathAssetsSource}: scans a prebuilt directory (a Vite
- * {@code dist/} or a static asset folder) and writes the {@code asset-manifest.json} that
- * classpath-based production serving requires. Meant to run from a consumer's build (e.g. via
- * exec-maven-plugin's {@code exec:java}), not from the running application — see {@code docs/build-time.md}.
- */
-public final class WebBundlerBuild {
- private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
-
- private WebBundlerBuild() {
- }
-
- /** Scans {@code distDir} and writes {@code distDir/asset-manifest.json} for classpath serving. */
- public static void generateManifest(Path distDir) {
- if (!Files.isDirectory(distDir)) {
- throw new IllegalArgumentException("Not a directory: " + distDir);
- }
- List entries = AssetDirectoryScanner.scan(distDir).stream()
- .map(asset -> new ClasspathAssetManifest.Entry(
- asset.canonicalPath(),
- AssetIo.stripLeadingSlash(asset.canonicalPath()),
- asset.mimeType(),
- asset.etag(),
- asset.immutable()))
- .toList();
- if (entries.isEmpty()) {
- throw new IllegalStateException("No assets found under " + distDir);
- }
- try {
- JSON.writeValue(distDir.resolve("asset-manifest.json").toFile(), new ClasspathAssetManifest(entries));
- } catch (IOException e) {
- throw new IllegalStateException("Failed to write asset-manifest.json in " + distDir, e);
- }
- }
-
- public static void main(String[] args) {
- if (args.length != 1) {
- System.err.println("Usage: java " + WebBundlerBuild.class.getName() + " ");
- System.exit(1);
- }
- generateManifest(Path.of(args[0]));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java
deleted file mode 100644
index 73415ef..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java
+++ /dev/null
@@ -1,223 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
-public final class WebBundlerConfig {
- private final RuntimeMode runtimeMode;
- private final OperationMode operationMode;
- private final FrontendType frontendType;
- private final PackageManager packageManager;
- private final InstallPolicy installPolicy;
- private final LoggingMode loggingMode;
- private final CommandSafetyMode commandSafetyMode;
- private final BasePathEnforcementMode basePathEnforcement;
- private final Path webRoot;
- private final String basePath;
- private final String devHost;
- private final int devPort;
- private final AssetsSource assetsSource;
- private final String indexFile;
- private final List watchList;
- private final String devCommand;
- private final String buildCommand;
- private final String installCommand;
- private final List safeCommandRegistry;
- private final String envRuntimeVariable;
-
- private WebBundlerConfig(Builder b) {
- this.runtimeMode = b.runtimeMode;
- this.operationMode = b.operationMode;
- this.frontendType = b.frontendType;
- this.packageManager = b.packageManager;
- this.installPolicy = b.installPolicy;
- this.loggingMode = b.loggingMode;
- this.commandSafetyMode = b.commandSafetyMode;
- this.basePathEnforcement = b.basePathEnforcement;
- this.webRoot = b.webRoot.normalize();
- this.basePath = sanitizeBasePath(b.basePath);
- this.devHost = b.devHost;
- this.devPort = b.devPort;
- this.assetsSource = b.assetsSource;
- this.indexFile = b.indexFile;
- this.watchList = List.copyOf(b.watchList);
- this.devCommand = blankToNull(b.devCommand);
- this.buildCommand = blankToNull(b.buildCommand);
- this.installCommand = blankToNull(b.installCommand);
- this.safeCommandRegistry = List.copyOf(b.safeCommandRegistry);
- this.envRuntimeVariable = b.envRuntimeVariable;
- validate();
- }
-
- public static Builder builder() {
- return new Builder();
- }
-
- public RuntimeMode runtimeMode() { return runtimeMode; }
- public OperationMode operationMode() { return operationMode; }
- public FrontendType frontendType() { return frontendType; }
- public PackageManager packageManager() { return packageManager; }
- public InstallPolicy installPolicy() { return installPolicy; }
- public LoggingMode loggingMode() { return loggingMode; }
- public CommandSafetyMode commandSafetyMode() { return commandSafetyMode; }
- public BasePathEnforcementMode basePathEnforcement() { return basePathEnforcement; }
- public Path webRoot() { return webRoot; }
- public String basePath() { return basePath; }
- public String devHost() { return devHost; }
- public int devPort() { return devPort; }
- public AssetsSource assetsSource() { return assetsSource; }
- public String indexFile() { return indexFile; }
- public List watchList() { return watchList; }
- public String devCommand() { return devCommand; }
- public String buildCommand() { return buildCommand; }
- public String installCommand() { return installCommand; }
- public List safeCommandRegistry() { return safeCommandRegistry; }
- public String envRuntimeVariable() { return envRuntimeVariable; }
-
- public Path expectedLockfile() {
- return webRoot.resolve(packageManager.lockfileName());
- }
-
- private void validate() {
- Objects.requireNonNull(runtimeMode, "runtimeMode");
- Objects.requireNonNull(operationMode, "operationMode");
- Objects.requireNonNull(frontendType, "frontendType");
- Objects.requireNonNull(packageManager, "packageManager");
- Objects.requireNonNull(installPolicy, "installPolicy");
- Objects.requireNonNull(loggingMode, "loggingMode");
- Objects.requireNonNull(commandSafetyMode, "commandSafetyMode");
- Objects.requireNonNull(basePathEnforcement, "basePathEnforcement");
- Objects.requireNonNull(webRoot, "webRoot");
- Objects.requireNonNull(assetsSource, "assetsSource");
- Objects.requireNonNull(indexFile, "indexFile");
- if (frontendType.requiresOrchestration()) {
- if (devPort <= 0 || devPort > 65535) {
- throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535");
- }
- if (watchList.isEmpty()) {
- throw new IllegalArgumentException("WebBundlerConfig: watchList must not be empty");
- }
- for (String path : watchList) {
- if (path == null || path.isBlank()) {
- throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries");
- }
- }
- }
- if (!basePath.startsWith("/")) {
- throw new IllegalArgumentException("WebBundlerConfig: basePath must start with '/'");
- }
- }
-
- private static String sanitizeBasePath(String raw) {
- if (raw == null || raw.isBlank()) return "/";
- String normalized = raw.trim();
- if (!normalized.startsWith("/")) normalized = "/" + normalized;
- if (normalized.length() > 1 && normalized.endsWith("/")) {
- normalized = normalized.substring(0, normalized.length() - 1);
- }
- return normalized;
- }
-
- private static String blankToNull(String value) {
- return value == null || value.isBlank() ? null : value.trim();
- }
-
- public static final class Builder {
- private RuntimeMode runtimeMode = RuntimeMode.AUTODETECT;
- private OperationMode operationMode = OperationMode.ORCHESTRATE_ONLY;
- private FrontendType frontendType = FrontendType.VITE;
- private PackageManager packageManager = PackageManager.NPM;
- private InstallPolicy installPolicy = InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED;
- private LoggingMode loggingMode = LoggingMode.MERGED;
- private CommandSafetyMode commandSafetyMode = CommandSafetyMode.WARN;
- private BasePathEnforcementMode basePathEnforcement = BasePathEnforcementMode.WARN_ONLY;
- private Path webRoot = Path.of("web");
- private String basePath = "/";
- private String devHost = "127.0.0.1";
- private int devPort = 5173;
- private AssetsSource assetsSource = defaultAssetsSource(FrontendType.VITE);
- private String indexFile = "index.html";
- private List watchList = defaultWatchList(PackageManager.NPM);
- private String devCommand;
- private String buildCommand;
- private String installCommand;
- private List safeCommandRegistry = defaultSafeRegistry();
- private String envRuntimeVariable = "FLASH_WEB_BUNDLER_MODE";
-
- public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; return this; }
- public Builder operationMode(OperationMode operationMode) { this.operationMode = operationMode; return this; }
- public Builder frontendType(FrontendType frontendType) {
- this.frontendType = frontendType;
- this.assetsSource = defaultAssetsSource(frontendType);
- this.operationMode = frontendType.requiresOrchestration() ? OperationMode.ORCHESTRATE_ONLY : OperationMode.MANAGED;
- return this;
- }
- public Builder packageManager(PackageManager packageManager) {
- this.packageManager = packageManager;
- this.watchList = defaultWatchList(packageManager);
- return this;
- }
- public Builder installPolicy(InstallPolicy installPolicy) { this.installPolicy = installPolicy; return this; }
- public Builder loggingMode(LoggingMode loggingMode) { this.loggingMode = loggingMode; return this; }
- public Builder commandSafetyMode(CommandSafetyMode commandSafetyMode) { this.commandSafetyMode = commandSafetyMode; return this; }
- public Builder basePathEnforcement(BasePathEnforcementMode basePathEnforcement) { this.basePathEnforcement = basePathEnforcement; return this; }
- public Builder webRoot(Path webRoot) { this.webRoot = webRoot; return this; }
- public Builder basePath(String basePath) { this.basePath = basePath; return this; }
- public Builder devHost(String devHost) { this.devHost = devHost; return this; }
- public Builder devPort(int devPort) { this.devPort = devPort; return this; }
- public Builder assetsSource(AssetsSource assetsSource) { this.assetsSource = assetsSource; return this; }
- public Builder assetsFromFilesystem(Path distDir) {
- this.assetsSource = FilesystemAssetsSource.of(distDir);
- return this;
- }
- public Builder assetsFromClasspath(String rootPrefix) {
- this.assetsSource = ClasspathAssetsSource.of(rootPrefix);
- return this;
- }
- public Builder assetsFromClasspath(String rootPrefix, String manifestResource) {
- this.assetsSource = ClasspathAssetsSource.of(rootPrefix, manifestResource);
- return this;
- }
- public Builder indexFile(String indexFile) { this.indexFile = indexFile; return this; }
- public Builder watchList(List watchList) { this.watchList = new ArrayList<>(watchList); return this; }
- public Builder devCommand(String devCommand) { this.devCommand = devCommand; return this; }
- public Builder buildCommand(String buildCommand) { this.buildCommand = buildCommand; return this; }
- public Builder installCommand(String installCommand) { this.installCommand = installCommand; return this; }
- public Builder safeCommandRegistry(List safeCommandRegistry) {
- this.safeCommandRegistry = new ArrayList<>(safeCommandRegistry);
- return this;
- }
- public Builder envRuntimeVariable(String envRuntimeVariable) { this.envRuntimeVariable = envRuntimeVariable; return this; }
-
- public WebBundlerConfig build() {
- return new WebBundlerConfig(this);
- }
-
- private static AssetsSource defaultAssetsSource(FrontendType type) {
- return type == FrontendType.STATIC
- ? FilesystemAssetsSource.of(Path.of("."))
- : FilesystemAssetsSource.of(Path.of("dist"));
- }
-
- private static List defaultWatchList(PackageManager manager) {
- return List.of(
- "package.json",
- manager.lockfileName(),
- "vite.config.ts",
- "vite.config.js",
- "tsconfig.json",
- ".env",
- ".env.local"
- );
- }
-
- private static List defaultSafeRegistry() {
- return List.of(
- "npm", "pnpm", "yarn", "bun", "npx",
- "npm.cmd", "pnpm.cmd", "yarn.cmd", "bun.cmd", "npx.cmd"
- );
- }
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java
deleted file mode 100644
index 3fc1c7c..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java
+++ /dev/null
@@ -1,156 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import dev.relism.flash.extension.FlashContext;
-import dev.relism.flash.extension.FlashRegistrar;
-import dev.relism.flash.extension.FlashExtension;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.nio.file.Path;
-import java.time.Duration;
-import java.util.List;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.TimeUnit;
-
-public final class WebBundlerExtension implements FlashExtension {
- private static final Logger log = LoggerFactory.getLogger(WebBundlerExtension.class);
-
- private final WebBundlerConfig config;
- private final ModeResolver modeResolver = new ModeResolver();
- private final FrontendTypeResolver frontendTypeResolver = new FrontendTypeResolver();
- private ScheduledExecutorService watchLoop;
- private Thread shutdownHook;
-
- public WebBundlerExtension() {
- this(WebBundlerConfig.builder().build());
- }
-
- public WebBundlerExtension(WebBundlerConfig config) {
- this.config = config;
- }
-
- @Override
- public void configure(FlashRegistrar> app, FlashContext ctx) {
- RuntimeEnvironment environment = modeResolver.resolve(config);
- PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config);
- FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType());
- CommandSafetyPolicy safetyPolicy = new CommandSafetyPolicy(config);
- CommandOrchestrator orchestrator = new CommandOrchestrator(config, safetyPolicy);
- WatchList watchList = new WatchList(config.webRoot());
- watchList.initialize(config.watchList());
-
- StaticAssetServingPolicy servingPolicy = null;
- SpaFallbackPolicy fallbackPolicy = null;
-
- try {
- if (environment == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration()) {
- bootstrapDev(strategy, pmAdapter, orchestrator, watchList);
- } else {
- AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest(
- config.basePath(),
- config.indexFile(),
- environment,
- config.webRoot()
- ));
- servingPolicy = new StaticAssetServingPolicy(catalog);
- fallbackPolicy = new SpaFallbackPolicy(catalog);
- log.info("flash-web-bundler loaded {} assets for production serving", catalog.size());
- }
-
- WebBundlerRuntime runtime = new WebBundlerRuntime(
- environment, servingPolicy, fallbackPolicy, orchestrator, watchList
- );
- registerShutdownHook(runtime);
- ctx.provide(WebBundlerRuntime.class, runtime);
- } catch (RuntimeException ex) {
- shutdownResources(orchestrator);
- throw ex;
- }
- ctx.onReady(() -> {
- WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
- boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration();
- if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
- return;
- }
-
- String basePath = config.basePath();
- String wildcard = "/".equals(basePath) ? "/**" : basePath + "/**";
-
- app.get(wildcard, (req, res) -> {
- if (runtime.servingPolicy().serve(req, res)) return null;
- runtime.fallbackPolicy().apply(res);
- return null;
- });
- app.head(wildcard, (req, res) -> {
- if (runtime.servingPolicy().serve(req, res)) {
- res.body(new byte[0]);
- return null;
- }
- runtime.fallbackPolicy().apply(res);
- res.body(new byte[0]);
- return null;
- });
- });
- }
-
- private void bootstrapDev(
- FrontendStrategy strategy,
- PackageManagerAdapter pmAdapter,
- CommandOrchestrator orchestrator,
- WatchList watchList
- ) {
- validateDevAssetsSource(config);
- InstallCache installCache = new InstallCache(config.webRoot());
- Path lock = config.expectedLockfile();
- if (config.installPolicy() == InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED && installCache.lockChanged(lock)) {
- orchestrator.runBlocking(pmAdapter.installCommand(), config.webRoot());
- installCache.writeLockHash(lock);
- }
-
- List devCommand = strategy.devCommand(config, pmAdapter);
- orchestrator.startDevServer(devCommand, config.webRoot());
- orchestrator.healthcheck(strategy.healthcheckPath(), Duration.ofSeconds(25));
- startWatchLoop(devCommand, orchestrator, watchList);
- log.info("flash-web-bundler started dev server at http://{}:{}", config.devHost(), config.devPort());
- }
-
- static void validateDevAssetsSource(WebBundlerConfig config) {
- if (config.assetsSource() instanceof ClasspathAssetsSource) {
- throw new IllegalStateException("Classpath assets source is not supported in DEV mode. Use assetsFromFilesystem(...) for DEV.");
- }
- }
-
- private void startWatchLoop(List devCommand, CommandOrchestrator orchestrator, WatchList watchList) {
- watchLoop = Executors.newSingleThreadScheduledExecutor(daemonThreadFactory("flash-web-bundler-watch"));
- watchLoop.scheduleWithFixedDelay(() -> {
- if (watchList.changed()) {
- log.warn("Watchlist changed. Restarting frontend dev server.");
- orchestrator.restartDevServer(devCommand, config.webRoot());
- }
- }, 1500L, 1500L, TimeUnit.MILLISECONDS);
- }
-
- private void registerShutdownHook(WebBundlerRuntime runtime) {
- shutdownHook = new Thread(() -> shutdownResources(runtime.orchestrator()), "flash-web-bundler-shutdown");
- shutdownHook.setDaemon(true);
- Runtime.getRuntime().addShutdownHook(shutdownHook);
- }
-
- private void shutdownResources(CommandOrchestrator orchestrator) {
- if (watchLoop != null) {
- watchLoop.shutdownNow();
- watchLoop = null;
- }
- orchestrator.close();
- }
-
- private static ThreadFactory daemonThreadFactory(String namePrefix) {
- return runnable -> {
- Thread thread = new Thread(runnable, namePrefix);
- thread.setDaemon(true);
- return thread;
- };
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java
deleted file mode 100644
index b825de2..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-final class WebBundlerRuntime {
- private final RuntimeEnvironment environment;
- private final StaticAssetServingPolicy servingPolicy;
- private final SpaFallbackPolicy fallbackPolicy;
- private final CommandOrchestrator orchestrator;
- private final WatchList watchList;
-
- WebBundlerRuntime(
- RuntimeEnvironment environment,
- StaticAssetServingPolicy servingPolicy,
- SpaFallbackPolicy fallbackPolicy,
- CommandOrchestrator orchestrator,
- WatchList watchList
- ) {
- this.environment = environment;
- this.servingPolicy = servingPolicy;
- this.fallbackPolicy = fallbackPolicy;
- this.orchestrator = orchestrator;
- this.watchList = watchList;
- }
-
- RuntimeEnvironment environment() { return environment; }
- StaticAssetServingPolicy servingPolicy() { return servingPolicy; }
- SpaFallbackPolicy fallbackPolicy() { return fallbackPolicy; }
- CommandOrchestrator orchestrator() { return orchestrator; }
- WatchList watchList() { return watchList; }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java
deleted file mode 100644
index 9ca7522..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class AssetsSourceTest {
- @TempDir
- Path tempDir;
-
- @Test
- void filesystemSource_loadsAssetsAndIndex() throws Exception {
- Path webRoot = tempDir.resolve("web");
- Path dist = webRoot.resolve("dist");
- Files.createDirectories(dist);
- Files.writeString(dist.resolve("index.html"), "ok");
- Files.writeString(dist.resolve("app.abcd1234.js"), "console.log(1)");
-
- AssetsSource source = FilesystemAssetsSource.of(Path.of("dist"));
- AssetCatalog catalog = source.load(new AssetLoadRequest("/app", "index.html", RuntimeEnvironment.PROD, webRoot));
-
- assertNotNull(catalog.index());
- AssetEntry js = catalog.find("/app/app.abcd1234.js");
- assertNotNull(js);
- assertTrue(js.immutable());
- }
-
- @Test
- void classpathSource_missingManifestFailsFast() {
- AssetsSource source = ClasspathAssetsSource.of("missing-root");
- IllegalStateException ex = assertThrows(IllegalStateException.class,
- () -> source.load(new AssetLoadRequest("/", "index.html", RuntimeEnvironment.PROD, Path.of("."))));
- assertTrue(ex.getMessage().contains("manifest"));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java
deleted file mode 100644
index 4d7d9a6..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-class CommandSafetyPolicyTest {
- @Test
- void block_mode_rejectsUnknownBinary() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .commandSafetyMode(CommandSafetyMode.BLOCK)
- .safeCommandRegistry(List.of("npm"))
- .build();
- CommandSafetyPolicy policy = new CommandSafetyPolicy(cfg);
- assertThrows(IllegalStateException.class, () -> policy.check(List.of("sh", "-c", "echo nope")));
- }
-
- @Test
- void allow_mode_permitsUnknownBinary() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .commandSafetyMode(CommandSafetyMode.ALLOW)
- .safeCommandRegistry(List.of("npm"))
- .build();
- CommandSafetyPolicy policy = new CommandSafetyPolicy(cfg);
- assertDoesNotThrow(() -> policy.check(List.of("custom", "run")));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java
deleted file mode 100644
index 24f8e53..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-class InstallCacheTest {
- @TempDir
- Path tempDir;
-
- @Test
- void lockHashChanges_areDetected() throws Exception {
- Path lockfile = tempDir.resolve("package-lock.json");
- Files.writeString(lockfile, "{\"a\":1}");
-
- InstallCache cache = new InstallCache(tempDir);
- assertTrue(cache.lockChanged(lockfile));
- cache.writeLockHash(lockfile);
- assertFalse(cache.lockChanged(lockfile));
-
- Files.writeString(lockfile, "{\"a\":2}");
- assertTrue(cache.lockChanged(lockfile));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java
deleted file mode 100644
index 7b8e248..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-class ModeResolverTest {
- private final ModeResolver resolver = new ModeResolver();
-
- @Test
- void prod_mode_isAlwaysProd() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .runtimeMode(RuntimeMode.PROD)
- .build();
- assertEquals(RuntimeEnvironment.PROD, resolver.resolve(cfg));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java
deleted file mode 100644
index 3cbe93b..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-class PackageManagerAdapterTest {
- @Test
- void pnpm_hasFrozenInstallCommand() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .packageManager(PackageManager.PNPM)
- .build();
- PackageManagerAdapter adapter = new PackageManagerAdapter(cfg);
- assertTrue(adapter.installCommand().get(0).startsWith("pnpm"));
- assertEquals("--frozen-lockfile", adapter.installCommand().get(2));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java
deleted file mode 100644
index a8ffe8a..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Comparator;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class WebBundlerBuildTest {
- private Path root;
-
- @AfterEach
- void tearDown() throws IOException {
- if (root == null || !Files.exists(root)) return;
- try (var walk = Files.walk(root)) {
- walk.sorted(Comparator.reverseOrder()).forEach(p -> {
- try {
- Files.delete(p);
- } catch (IOException ignored) {
- }
- });
- }
- }
-
- /**
- * {@link ClasspathAssetsSource} resolves manifest + resources via the classloader, so the
- * scanned directory needs to actually be on the test classpath — using the directory the test
- * class itself was loaded from (Maven: {@code target/test-classes}) keeps this portable across
- * runners instead of hardcoding a build-tool-specific path.
- */
- @Test
- void generateManifest_isConsumableByClasspathAssetsSource() throws Exception {
- Path testClasses = Path.of(WebBundlerBuildTest.class.getProtectionDomain().getCodeSource().getLocation().toURI());
- root = testClasses.resolve("web-bundler-build-test-" + System.nanoTime());
- Files.createDirectories(root);
- Files.writeString(root.resolve("index.html"), "built");
- Files.writeString(root.resolve("app.a1b2c3d4.js"), "console.log('built')");
-
- WebBundlerBuild.generateManifest(root);
- assertTrue(Files.exists(root.resolve("asset-manifest.json")));
-
- String rootPrefix = testClasses.relativize(root).toString().replace('\\', '/');
- ClasspathAssetsSource source = ClasspathAssetsSource.of(rootPrefix);
- AssetCatalog catalog = source.load(new AssetLoadRequest("/", "index.html", RuntimeEnvironment.PROD, Path.of(".")));
-
- assertNotNull(catalog.index());
- assertTrue(new String(catalog.index().raw()).contains("built"));
-
- AssetEntry js = catalog.find("/app.a1b2c3d4.js");
- assertNotNull(js);
- assertTrue(js.immutable());
- assertEquals("text/javascript", js.mimeType());
- assertFalse(catalog.index().immutable());
- }
-
- @Test
- void generateManifest_emptyDirectory_throws() throws Exception {
- Path testClasses = Path.of(WebBundlerBuildTest.class.getProtectionDomain().getCodeSource().getLocation().toURI());
- root = testClasses.resolve("web-bundler-build-empty-" + System.nanoTime());
- Files.createDirectories(root);
-
- assertThrows(IllegalStateException.class, () -> WebBundlerBuild.generateManifest(root));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java
deleted file mode 100644
index 1e3542c..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.Test;
-
-import java.nio.file.Path;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class WebBundlerConfigTest {
- @Test
- void defaults_areSane() {
- WebBundlerConfig cfg = WebBundlerConfig.builder().build();
- assertEquals(RuntimeMode.AUTODETECT, cfg.runtimeMode());
- assertEquals(OperationMode.ORCHESTRATE_ONLY, cfg.operationMode());
- assertEquals(InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED, cfg.installPolicy());
- assertEquals(LoggingMode.MERGED, cfg.loggingMode());
- assertEquals(CommandSafetyMode.WARN, cfg.commandSafetyMode());
- }
-
- @Test
- void packageManager_setsLockfileInWatchList() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .packageManager(PackageManager.PNPM)
- .build();
- assertTrue(cfg.watchList().contains("pnpm-lock.yaml"));
- }
-
- @Test
- void basePath_isNormalized() {
- WebBundlerConfig cfg = WebBundlerConfig.builder().basePath("app/").build();
- assertEquals("/app", cfg.basePath());
- }
-
- @Test
- void invalidPort_throws() {
- assertThrows(IllegalArgumentException.class, () ->
- WebBundlerConfig.builder().devPort(0).build());
- }
-
- @Test
- void assetsFromFilesystem_setsFilesystemSource() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .webRoot(Path.of("frontend"))
- .assetsFromFilesystem(Path.of("build"))
- .watchList(List.of("package.json"))
- .build();
- assertTrue(cfg.assetsSource() instanceof FilesystemAssetsSource);
- }
-
- @Test
- void assetsFromClasspath_setsClasspathSource() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .assetsFromClasspath("web/dist")
- .build();
- assertTrue(cfg.assetsSource() instanceof ClasspathAssetsSource);
- }
-
- @Test
- void staticFrontend_defaultsToManagedAndFilesystemSource() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .frontendType(FrontendType.STATIC)
- .build();
- assertEquals(OperationMode.MANAGED, cfg.operationMode());
- assertTrue(cfg.assetsSource() instanceof FilesystemAssetsSource);
- }
-
- @Test
- void staticFrontend_skipsOrchestrationValidation() {
- assertDoesNotThrow(() -> WebBundlerConfig.builder()
- .frontendType(FrontendType.STATIC)
- .devPort(0)
- .watchList(List.of())
- .build());
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java
deleted file mode 100644
index 45e0c2a..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-class WebBundlerExtensionDevGuardTest {
- @Test
- void classpathSourceInDev_isRejected() {
- WebBundlerConfig cfg = WebBundlerConfig.builder()
- .assetsFromClasspath("web/dist")
- .build();
-
- assertThrows(IllegalStateException.class, () -> WebBundlerExtension.validateDevAssetsSource(cfg));
- }
-}
diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java
deleted file mode 100644
index 6060fa4..0000000
--- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package dev.relism.flash.ext.webbundler;
-
-import dev.relism.flash.testing.FlashTest;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.RegisterExtension;
-import org.junit.jupiter.api.io.TempDir;
-
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-/**
- * Two frontend layouts served by a real server. Each is laid out on disk inside the
- * application's own configure(), which the harness runs lazily at first access — by then
- * {@link TempDir} is populated, and a server whose test never runs is never booted.
- */
-class WebBundlerExtensionIntegrationTest {
-
- @TempDir
- static Path tempDir;
-
- @RegisterExtension
- static FlashTest managed = FlashTest.of(app -> {
- Path webRoot = write(tempDir.resolve("web").resolve("dist"),
- "index.html", "spa",
- "app.js", "console.log('ok');").getParent();
-
- app.install(new WebBundlerExtension(WebBundlerConfig.builder()
- .runtimeMode(RuntimeMode.PROD)
- .operationMode(OperationMode.MANAGED)
- .webRoot(webRoot)
- .assetsFromFilesystem(Path.of("dist"))
- .basePath("/app")
- .build()));
- app.get("/api/ping", (req, res) -> "pong");
- });
-
- // PROD is deterministic in a test JVM (Flash.DEV depends on env/system-property detection
- // that can't be forced per-test); STATIC's actual guarantee — that it never orchestrates,
- // in DEV or PROD — is enforced structurally by the same requiresOrchestration() gate in
- // both WebBundlerExtension.provide() and .routes(), not by this test.
- @RegisterExtension
- static FlashTest staticFrontend = FlashTest.of(app -> {
- Path webRoot = write(tempDir.resolve("public"),
- "index.html", "static",
- "style.css", "body{color:red}");
-
- app.install(new WebBundlerExtension(WebBundlerConfig.builder()
- .runtimeMode(RuntimeMode.PROD)
- .frontendType(FrontendType.STATIC)
- .webRoot(webRoot)
- .build()));
- app.get("/api/ping", (req, res) -> "pong");
- });
-
- @Test
- void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() {
- managed.get("/api/ping").expectStatus(200).expectBody("pong");
- managed.get("/app/app.js").expectStatus(200).expectBodyContains("console.log");
- managed.get("/app/some/client/route").expectStatus(200).expectBodyContains("spa");
- }
-
- @Test
- void staticFrontend_servesAssetsWithoutOrchestration() {
- staticFrontend.get("/api/ping").expectStatus(200).expectBody("pong");
- staticFrontend.get("/style.css").expectStatus(200).expectBodyContains("color:red");
- }
-
- /** Creates {@code directory} and writes the given name/content pairs into it. */
- private static Path write(Path directory, String... nameThenContent) {
- try {
- Files.createDirectories(directory);
- for (int i = 0; i < nameThenContent.length; i += 2)
- Files.writeString(directory.resolve(nameThenContent[i]), nameThenContent[i + 1]);
- return directory;
- } catch (IOException failure) {
- throw new UncheckedIOException(failure);
- }
- }
-}
diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml
index bbbf68b..986162c 100644
--- a/flash-extensions/pom.xml
+++ b/flash-extensions/pom.xml
@@ -27,7 +27,8 @@
flash-ext-view-jte
flash-ext-view-thymeleaf
flash-ext-limiter
- flash-ext-web-bundler
+ flash-ext-vite
+ flash-ext-vite-maven-plugin
flash-ext-mcp
flash-ext-validation
flash-ext-scheduler
@@ -119,7 +120,7 @@
dev.relism
- flash-ext-web-bundler
+ flash-ext-vite
${project.version}
diff --git a/flash/src/main/java/dev/relism/flash/models/Response.java b/flash/src/main/java/dev/relism/flash/models/Response.java
index 246c1bd..a49d299 100644
--- a/flash/src/main/java/dev/relism/flash/models/Response.java
+++ b/flash/src/main/java/dev/relism/flash/models/Response.java
@@ -171,6 +171,13 @@ public class Response {
return this;
}
+ /** A content type encoded once, typically at boot: the array is kept, not copied, so it must not change. */
+ public Response type(byte[] ct) {
+ checkActive();
+ this.contentType = ct;
+ return this;
+ }
+
public Response type(String ct) {
checkActive();
this.contentType = ct.getBytes(StandardCharsets.UTF_8);
diff --git a/pom.xml b/pom.xml
index d5546cc..e06dad3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -136,7 +136,7 @@
dev.relism
- flash-ext-web-bundler
+ flash-ext-vite
${project.version}