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 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-11 00:22:26 +00:00
co-authored by Claude Sonnet 5
parent fa0a2d79b4
commit 8ece9975de
16 changed files with 399 additions and 54 deletions
@@ -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<ScannedAsset> scan(Path root) {
Map<String, Builder> 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<ScannedAsset> 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;
}
}
}
@@ -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<String, AssetEntryBuilder> 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<String, AssetEntry> byRoute = new HashMap<>();
for (Map.Entry<String, AssetEntryBuilder> 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;
}
}
}
@@ -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;
}
}
@@ -8,6 +8,7 @@ final class FrontendTypeResolver {
FrontendTypeResolver() {
register(new ViteFrontendStrategy());
register(new StaticFrontendStrategy());
}
void register(FrontendStrategy strategy) {
@@ -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<String> devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
throw new UnsupportedOperationException("STATIC frontend type has no dev command");
}
@Override
public List<String> buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
throw new UnsupportedOperationException("STATIC frontend type has no build command");
}
}
@@ -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<ClasspathAssetManifest.Entry> 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() + " <distDir>");
System.exit(1);
}
generateManifest(Path.of(args[0]));
}
}
@@ -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<String> 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<String> defaultWatchList(PackageManager manager) {
return List.of(
"package.json",
@@ -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;
}
@@ -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"), "<html>built</html>");
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));
}
}
@@ -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());
}
}
@@ -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"), "<html>static</html>");
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<String> 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<String> 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"));
}
}