From 8ece9975dec6af4431538df594ef541299af071c Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Tue, 11 Aug 2026 00:22:26 +0000 Subject: [PATCH] feat(ext-web-bundler): add build-time asset scanning and static-frontend strategy Introduces AssetDirectoryScanner, StaticFrontendStrategy, and WebBundlerBuild for build-time asset discovery, plus config/docs updates for frontend-type resolution. Co-Authored-By: Claude Sonnet 5 --- .../docs/asset-sources.md | 9 ++- .../flash-ext-web-bundler/docs/build-time.md | 43 ++++++++++++ .../docs/configuration.md | 12 +++- .../docs/frontend-selection.md | 24 ++++++- .../flash-ext-web-bundler/docs/modes.md | 9 +++ .../ext/webbundler/AssetDirectoryScanner.java | 60 ++++++++++++++++ .../webbundler/FilesystemAssetsSource.java | 38 +---------- .../flash/ext/webbundler/FrontendType.java | 14 +++- .../ext/webbundler/FrontendTypeResolver.java | 1 + .../webbundler/StaticFrontendStrategy.java | 21 ++++++ .../flash/ext/webbundler/WebBundlerBuild.java | 53 +++++++++++++++ .../ext/webbundler/WebBundlerConfig.java | 35 +++++++--- .../ext/webbundler/WebBundlerExtension.java | 5 +- .../ext/webbundler/WebBundlerBuildTest.java | 68 +++++++++++++++++++ .../ext/webbundler/WebBundlerConfigTest.java | 18 +++++ .../WebBundlerExtensionIntegrationTest.java | 43 ++++++++++++ 16 files changed, 399 insertions(+), 54 deletions(-) create mode 100644 flash-extensions/flash-ext-web-bundler/docs/build-time.md create mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java create mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java create mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java create mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java 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")); + } + }