diff --git a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md b/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md
index 216778e..a0a82bf 100644
--- a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md
+++ b/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md
@@ -13,7 +13,14 @@ Builder shortcuts:
- `.assetsFromClasspath("web/dist")`
Classpath source requires `asset-manifest.json` generated at build time.
-The developer does not maintain this file manually.
+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:
diff --git a/flash-extensions/flash-ext-web-bundler/docs/build-time.md b/flash-extensions/flash-ext-web-bundler/docs/build-time.md
new file mode 100644
index 0000000..8276599
--- /dev/null
+++ b/flash-extensions/flash-ext-web-bundler/docs/build-time.md
@@ -0,0 +1,43 @@
+# 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
index 60b1be8..1c8b8f6 100644
--- a/flash-extensions/flash-ext-web-bundler/docs/configuration.md
+++ b/flash-extensions/flash-ext-web-bundler/docs/configuration.md
@@ -6,7 +6,7 @@ Key fields:
- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT`
- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
-- `frontendType`: currently `VITE`
+- `frontendType`: `VITE`, `STATIC` — see `frontend-selection.md`
- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
@@ -25,6 +25,12 @@ Or by direct source object:
Validation is fail-fast:
-- invalid `devPort`
-- blank/invalid watch entries
+- 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/frontend-selection.md b/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md
index 4712bce..e72fdb4 100644
--- a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md
+++ b/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md
@@ -4,7 +4,29 @@ Frontend integration is explicit through `frontendType`.
- No heuristic detection in v1.
- Deterministic mapping: `FrontendType -> FrontendStrategy`.
-- Current built-in strategy: `VITE`.
+- 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:
diff --git a/flash-extensions/flash-ext-web-bundler/docs/modes.md b/flash-extensions/flash-ext-web-bundler/docs/modes.md
index adeeebc..11723b1 100644
--- a/flash-extensions/flash-ext-web-bundler/docs/modes.md
+++ b/flash-extensions/flash-ext-web-bundler/docs/modes.md
@@ -10,3 +10,12 @@
- `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/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
new file mode 100644
index 0000000..088198b
--- /dev/null
+++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java
@@ -0,0 +1,60 @@
+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/FilesystemAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java
index 1530378..769c07b 100644
--- 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
@@ -1,6 +1,5 @@
package dev.relism.flash.ext.webbundler;
-import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
@@ -25,30 +24,10 @@ public final class FilesystemAssetsSource implements AssetsSource {
throw new IllegalStateException("distDir does not exist: " + 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);
- String routePath = AssetPaths.joinBase(request.basePath(), canonical);
- AssetEntryBuilder b = builders.computeIfAbsent(routePath, k -> new AssetEntryBuilder(canonical));
- 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 preload assets from " + root, e);
- }
-
Map byRoute = new HashMap<>();
- for (Map.Entry e : builders.entrySet()) {
- AssetEntryBuilder b = e.getValue();
- if (b.raw == null) continue;
- String etag = AssetIo.quotedSha1(b.raw);
- String mime = MimeTypes.byPath(b.canonicalPath);
- boolean immutable = AssetIo.isFingerprinted(b.canonicalPath);
- byRoute.put(e.getKey(), new AssetEntry(b.raw, b.br, b.gz, etag, mime, immutable));
+ 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());
@@ -58,15 +37,4 @@ public final class FilesystemAssetsSource implements AssetsSource {
}
return new AssetCatalog(byRoute, index);
}
-
- private static final class AssetEntryBuilder {
- private final String canonicalPath;
- private byte[] raw;
- private byte[] br;
- private byte[] gz;
-
- private AssetEntryBuilder(String canonicalPath) {
- this.canonicalPath = canonicalPath;
- }
- }
}
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
index c39d37d..c980410 100644
--- 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
@@ -1,5 +1,17 @@
package dev.relism.flash.ext.webbundler;
public enum FrontendType {
- VITE
+ 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
index e596fae..05b39da 100644
--- 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
@@ -8,6 +8,7 @@ final class FrontendTypeResolver {
FrontendTypeResolver() {
register(new ViteFrontendStrategy());
+ register(new StaticFrontendStrategy());
}
void register(FrontendStrategy strategy) {
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
new file mode 100644
index 0000000..ed34e83
--- /dev/null
+++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java
@@ -0,0 +1,21 @@
+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/WebBundlerBuild.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java
new file mode 100644
index 0000000..678323c
--- /dev/null
+++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java
@@ -0,0 +1,53 @@
+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
index 5a881df..73415ef 100644
--- 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
@@ -92,15 +92,17 @@ public final class WebBundlerConfig {
Objects.requireNonNull(webRoot, "webRoot");
Objects.requireNonNull(assetsSource, "assetsSource");
Objects.requireNonNull(indexFile, "indexFile");
- 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 (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("/")) {
@@ -135,7 +137,7 @@ public final class WebBundlerConfig {
private String basePath = "/";
private String devHost = "127.0.0.1";
private int devPort = 5173;
- private AssetsSource assetsSource = FilesystemAssetsSource.of(Path.of("dist"));
+ private AssetsSource assetsSource = defaultAssetsSource(FrontendType.VITE);
private String indexFile = "index.html";
private List watchList = defaultWatchList(PackageManager.NPM);
private String devCommand;
@@ -146,7 +148,12 @@ public final class WebBundlerConfig {
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; 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);
@@ -188,6 +195,12 @@ public final class WebBundlerConfig {
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",
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
index fc8d274..b92ed76 100644
--- 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
@@ -51,7 +51,7 @@ public final class WebBundlerExtension implements FlashExtension {
SpaFallbackPolicy fallbackPolicy = null;
try {
- if (environment == RuntimeEnvironment.DEV) {
+ if (environment == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration()) {
bootstrapDev(strategy, pmAdapter, orchestrator, watchList);
} else {
AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest(
@@ -79,7 +79,8 @@ public final class WebBundlerExtension implements FlashExtension {
@Override
public void routes(FlashRegistrar> app, FlashContext ctx) {
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
- if (runtime.environment() == RuntimeEnvironment.DEV || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
+ boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration();
+ if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
return;
}
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
new file mode 100644
index 0000000..a8ffe8a
--- /dev/null
+++ b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java
@@ -0,0 +1,68 @@
+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
index 6be98d8..1e3542c 100644
--- 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
@@ -55,4 +55,22 @@ class WebBundlerConfigTest {
.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/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java
index a09fec6..df7c443 100644
--- 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
@@ -76,4 +76,47 @@ class WebBundlerExtensionIntegrationTest {
assertTrue(fallback.body().contains("spa"));
}
+ @Test
+ void staticFrontend_servesAssetsWithoutOrchestration() throws Exception {
+ Path webRoot = tempDir.resolve("public");
+ Files.createDirectories(webRoot);
+ Files.writeString(webRoot.resolve("index.html"), "static");
+ Files.writeString(webRoot.resolve("style.css"), "body{color:red}");
+
+ int port;
+ try (ServerSocket s = new ServerSocket(0)) {
+ port = s.getLocalPort();
+ }
+
+ // 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.
+ WebBundlerConfig config = WebBundlerConfig.builder()
+ .runtimeMode(RuntimeMode.PROD)
+ .frontendType(FrontendType.STATIC)
+ .webRoot(webRoot)
+ .build();
+
+ app = FlashApp.create(port);
+ app.install(new WebBundlerExtension(config));
+ app.get("/api/ping", (req, res) -> "pong");
+ app.start();
+
+ HttpClient client = HttpClient.newHttpClient();
+ HttpResponse backend = client.send(
+ HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/ping")).GET().build(),
+ HttpResponse.BodyHandlers.ofString()
+ );
+ assertEquals(200, backend.statusCode());
+ assertEquals("pong", backend.body());
+
+ HttpResponse asset = client.send(
+ HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/style.css")).GET().build(),
+ HttpResponse.BodyHandlers.ofString()
+ );
+ assertEquals(200, asset.statusCode());
+ assertTrue(asset.body().contains("color:red"));
+ }
+
}