Feature/core/deterministic boot graph #6
@@ -13,7 +13,14 @@ Builder shortcuts:
|
|||||||
- `.assetsFromClasspath("web/dist")`
|
- `.assetsFromClasspath("web/dist")`
|
||||||
|
|
||||||
Classpath source requires `asset-manifest.json` generated at build time.
|
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:
|
Production startup is fail-fast if:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>web-bundler-manifest</id>
|
||||||
|
<phase>process-classes</phase>
|
||||||
|
<goals><goal>java</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>dev.relism.flash.ext.webbundler.WebBundlerBuild</mainClass>
|
||||||
|
<arguments>
|
||||||
|
<argument>${project.build.outputDirectory}/web/dist</argument>
|
||||||
|
</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
```
|
||||||
|
|
||||||
|
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")`.
|
||||||
@@ -6,7 +6,7 @@ Key fields:
|
|||||||
|
|
||||||
- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT`
|
- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT`
|
||||||
- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
|
- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
|
||||||
- `frontendType`: currently `VITE`
|
- `frontendType`: `VITE`, `STATIC` — see `frontend-selection.md`
|
||||||
- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
|
- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
|
||||||
- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
|
- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
|
||||||
- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
|
- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
|
||||||
@@ -25,6 +25,12 @@ Or by direct source object:
|
|||||||
|
|
||||||
Validation is fail-fast:
|
Validation is fail-fast:
|
||||||
|
|
||||||
- invalid `devPort`
|
- invalid `devPort` (only when `frontendType` requires orchestration — skipped for `STATIC`)
|
||||||
- blank/invalid watch entries
|
- blank/invalid watch entries (same — skipped for `STATIC`)
|
||||||
- invalid `basePath`
|
- 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.
|
||||||
|
|||||||
@@ -4,7 +4,29 @@ Frontend integration is explicit through `frontendType`.
|
|||||||
|
|
||||||
- No heuristic detection in v1.
|
- No heuristic detection in v1.
|
||||||
- Deterministic mapping: `FrontendType -> FrontendStrategy`.
|
- 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:
|
Extension points:
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,12 @@
|
|||||||
|
|
||||||
- `ORCHESTRATE_ONLY`: only orchestrates dev tooling.
|
- `ORCHESTRATE_ONLY`: only orchestrates dev tooling.
|
||||||
- `MANAGED`: enables production serving + SPA fallback routes.
|
- `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.
|
||||||
|
|||||||
+60
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-35
@@ -1,6 +1,5 @@
|
|||||||
package dev.relism.flash.ext.webbundler;
|
package dev.relism.flash.ext.webbundler;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -25,30 +24,10 @@ public final class FilesystemAssetsSource implements AssetsSource {
|
|||||||
throw new IllegalStateException("distDir does not exist: " + root);
|
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<>();
|
Map<String, AssetEntry> byRoute = new HashMap<>();
|
||||||
for (Map.Entry<String, AssetEntryBuilder> e : builders.entrySet()) {
|
for (AssetDirectoryScanner.ScannedAsset asset : AssetDirectoryScanner.scan(root)) {
|
||||||
AssetEntryBuilder b = e.getValue();
|
String routePath = AssetPaths.joinBase(request.basePath(), asset.canonicalPath());
|
||||||
if (b.raw == null) continue;
|
byRoute.put(routePath, new AssetEntry(asset.raw(), asset.br(), asset.gz(), asset.etag(), asset.mimeType(), asset.immutable()));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
|
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
|
||||||
@@ -58,15 +37,4 @@ public final class FilesystemAssetsSource implements AssetsSource {
|
|||||||
}
|
}
|
||||||
return new AssetCatalog(byRoute, index);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -1,5 +1,17 @@
|
|||||||
package dev.relism.flash.ext.webbundler;
|
package dev.relism.flash.ext.webbundler;
|
||||||
|
|
||||||
public enum FrontendType {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ final class FrontendTypeResolver {
|
|||||||
|
|
||||||
FrontendTypeResolver() {
|
FrontendTypeResolver() {
|
||||||
register(new ViteFrontendStrategy());
|
register(new ViteFrontendStrategy());
|
||||||
|
register(new StaticFrontendStrategy());
|
||||||
}
|
}
|
||||||
|
|
||||||
void register(FrontendStrategy strategy) {
|
void register(FrontendStrategy strategy) {
|
||||||
|
|||||||
+21
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-2
@@ -92,6 +92,7 @@ public final class WebBundlerConfig {
|
|||||||
Objects.requireNonNull(webRoot, "webRoot");
|
Objects.requireNonNull(webRoot, "webRoot");
|
||||||
Objects.requireNonNull(assetsSource, "assetsSource");
|
Objects.requireNonNull(assetsSource, "assetsSource");
|
||||||
Objects.requireNonNull(indexFile, "indexFile");
|
Objects.requireNonNull(indexFile, "indexFile");
|
||||||
|
if (frontendType.requiresOrchestration()) {
|
||||||
if (devPort <= 0 || devPort > 65535) {
|
if (devPort <= 0 || devPort > 65535) {
|
||||||
throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535");
|
throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535");
|
||||||
}
|
}
|
||||||
@@ -103,6 +104,7 @@ public final class WebBundlerConfig {
|
|||||||
throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries");
|
throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!basePath.startsWith("/")) {
|
if (!basePath.startsWith("/")) {
|
||||||
throw new IllegalArgumentException("WebBundlerConfig: basePath must start with '/'");
|
throw new IllegalArgumentException("WebBundlerConfig: basePath must start with '/'");
|
||||||
}
|
}
|
||||||
@@ -135,7 +137,7 @@ public final class WebBundlerConfig {
|
|||||||
private String basePath = "/";
|
private String basePath = "/";
|
||||||
private String devHost = "127.0.0.1";
|
private String devHost = "127.0.0.1";
|
||||||
private int devPort = 5173;
|
private int devPort = 5173;
|
||||||
private AssetsSource assetsSource = FilesystemAssetsSource.of(Path.of("dist"));
|
private AssetsSource assetsSource = defaultAssetsSource(FrontendType.VITE);
|
||||||
private String indexFile = "index.html";
|
private String indexFile = "index.html";
|
||||||
private List<String> watchList = defaultWatchList(PackageManager.NPM);
|
private List<String> watchList = defaultWatchList(PackageManager.NPM);
|
||||||
private String devCommand;
|
private String devCommand;
|
||||||
@@ -146,7 +148,12 @@ public final class WebBundlerConfig {
|
|||||||
|
|
||||||
public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; return this; }
|
public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; return this; }
|
||||||
public Builder operationMode(OperationMode operationMode) { this.operationMode = operationMode; 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) {
|
public Builder packageManager(PackageManager packageManager) {
|
||||||
this.packageManager = packageManager;
|
this.packageManager = packageManager;
|
||||||
this.watchList = defaultWatchList(packageManager);
|
this.watchList = defaultWatchList(packageManager);
|
||||||
@@ -188,6 +195,12 @@ public final class WebBundlerConfig {
|
|||||||
return new WebBundlerConfig(this);
|
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) {
|
private static List<String> defaultWatchList(PackageManager manager) {
|
||||||
return List.of(
|
return List.of(
|
||||||
"package.json",
|
"package.json",
|
||||||
|
|||||||
+3
-2
@@ -51,7 +51,7 @@ public final class WebBundlerExtension implements FlashExtension {
|
|||||||
SpaFallbackPolicy fallbackPolicy = null;
|
SpaFallbackPolicy fallbackPolicy = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (environment == RuntimeEnvironment.DEV) {
|
if (environment == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration()) {
|
||||||
bootstrapDev(strategy, pmAdapter, orchestrator, watchList);
|
bootstrapDev(strategy, pmAdapter, orchestrator, watchList);
|
||||||
} else {
|
} else {
|
||||||
AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest(
|
AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest(
|
||||||
@@ -79,7 +79,8 @@ public final class WebBundlerExtension implements FlashExtension {
|
|||||||
@Override
|
@Override
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -55,4 +55,22 @@ class WebBundlerConfigTest {
|
|||||||
.build();
|
.build();
|
||||||
assertTrue(cfg.assetsSource() instanceof ClasspathAssetsSource);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -76,4 +76,47 @@ class WebBundlerExtensionIntegrationTest {
|
|||||||
assertTrue(fallback.body().contains("spa"));
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user