weeks of bullshit

This commit is contained in:
Relism
2026-04-17 08:18:11 +02:00
parent b5d4481502
commit 9efbe38c0c
157 changed files with 5171 additions and 1273 deletions
@@ -0,0 +1,33 @@
package dev.relism.ext.webbundler;
import java.util.Map;
public final class AssetCatalog {
private final Map<String, AssetEntry> byRoutePath;
private final AssetEntry index;
AssetCatalog(Map<String, AssetEntry> byRoutePath, AssetEntry index) {
this.byRoutePath = Map.copyOf(byRoutePath);
this.index = index;
}
AssetEntry find(String routePath) {
return byRoutePath.get(routePath);
}
AssetEntry index() {
return index;
}
public AssetEntry asset(String routePath) {
return find(routePath);
}
public AssetEntry indexAsset() {
return index;
}
public int size() {
return byRoutePath.size();
}
}
@@ -0,0 +1,11 @@
package dev.relism.ext.webbundler;
public record AssetEntry(
byte[] raw,
byte[] br,
byte[] gz,
String etag,
String mimeType,
boolean immutable
) {
}
@@ -0,0 +1,93 @@
package dev.relism.ext.webbundler;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
final class AssetIo {
private AssetIo() {
}
static byte[] read(Path path) {
try {
return Files.readAllBytes(path);
} catch (IOException e) {
throw new IllegalStateException("Failed reading asset " + path, e);
}
}
static byte[] readClasspath(String path) {
try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) {
if (in == null) throw new IllegalStateException("Missing classpath resource: " + path);
return in.readAllBytes();
} catch (IOException e) {
throw new IllegalStateException("Failed reading classpath resource: " + path, e);
}
}
static byte[] readClasspathOrNull(String path) {
try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) {
if (in == null) return null;
return in.readAllBytes();
} catch (IOException e) {
throw new IllegalStateException("Failed reading classpath resource: " + path, e);
}
}
static String quotedSha1(byte[] data) {
return "\"" + sha1Hex(data) + "\"";
}
private static String sha1Hex(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] hash = md.digest(data);
StringBuilder out = new StringBuilder(hash.length * 2);
for (byte b : hash) {
out.append(String.format("%02x", b));
}
return out.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-1 unavailable", e);
}
}
static String stripLeadingSlash(String path) {
if (path == null || path.isBlank()) return path;
return path.startsWith("/") ? path.substring(1) : path;
}
static String normalizePath(String path) {
if (path == null || path.isBlank()) return "/";
String p = path.replace('\\', '/');
if (!p.startsWith("/")) p = "/" + p;
return p;
}
static String stripBrGzSuffix(String path) {
if (path.endsWith(".br")) return path.substring(0, path.length() - 3);
if (path.endsWith(".gz")) return path.substring(0, path.length() - 3);
return path;
}
static boolean isFingerprinted(String path) {
String file = path;
int slash = file.lastIndexOf('/');
if (slash >= 0) file = file.substring(slash + 1);
int dot = file.lastIndexOf('.');
if (dot <= 0) return false;
int prevDot = file.lastIndexOf('.', dot - 1);
if (prevDot <= 0) return false;
String token = file.substring(prevDot + 1, dot);
if (token.length() < 8) return false;
for (int i = 0; i < token.length(); i++) {
char c = token.charAt(i);
boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) return false;
}
return true;
}
}
@@ -0,0 +1,12 @@
package dev.relism.ext.webbundler;
import java.nio.file.Path;
public record AssetLoadRequest(String basePath, String indexFile, RuntimeEnvironment environment, Path webRoot) {
public AssetLoadRequest {
if (basePath == null || basePath.isBlank()) throw new IllegalArgumentException("basePath is required");
if (indexFile == null || indexFile.isBlank()) throw new IllegalArgumentException("indexFile is required");
if (environment == null) throw new IllegalArgumentException("environment is required");
if (webRoot == null) throw new IllegalArgumentException("webRoot is required");
}
}
@@ -0,0 +1,16 @@
package dev.relism.ext.webbundler;
record AssetMetadata(
String routePath,
String logicalPath,
String mimeType,
String etag,
boolean immutable
) {
AssetMetadata {
if (routePath == null || routePath.isBlank()) throw new IllegalArgumentException("routePath is required");
if (logicalPath == null || logicalPath.isBlank()) throw new IllegalArgumentException("logicalPath is required");
if (mimeType == null || mimeType.isBlank()) throw new IllegalArgumentException("mimeType is required");
if (etag == null || etag.isBlank()) throw new IllegalArgumentException("etag is required");
}
}
@@ -0,0 +1,26 @@
package dev.relism.ext.webbundler;
final class AssetPaths {
private AssetPaths() {
}
static String joinBase(String basePath, String routePath) {
String base = "/".equals(basePath) ? "" : basePath;
String route = AssetIo.normalizePath(routePath);
return base + route;
}
static String normalizePrefix(String prefix) {
if (prefix == null || prefix.isBlank()) return "";
String p = prefix.replace('\\', '/');
if (p.startsWith("/")) p = p.substring(1);
while (p.endsWith("/")) p = p.substring(0, p.length() - 1);
return p;
}
static String classpathJoin(String prefix, String relative) {
String p = normalizePrefix(prefix);
String r = AssetIo.stripLeadingSlash(relative).replace('\\', '/');
return p.isEmpty() ? r : p + "/" + r;
}
}
@@ -0,0 +1,5 @@
package dev.relism.ext.webbundler;
public interface AssetsSource {
AssetCatalog load(AssetLoadRequest request);
}
@@ -0,0 +1,16 @@
package dev.relism.ext.webbundler;
import java.nio.file.Path;
public final class AssetsSources {
private AssetsSources() {
}
public static AssetsSource filesystem(Path distDir) {
return FilesystemAssetsSource.of(distDir);
}
public static AssetsSource classpath(String rootPrefix) {
return ClasspathAssetsSource.of(rootPrefix);
}
}
@@ -0,0 +1,6 @@
package dev.relism.ext.webbundler;
public enum BasePathEnforcementMode {
WARN_ONLY,
STRICT
}
@@ -0,0 +1,16 @@
package dev.relism.ext.webbundler;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
record ClasspathAssetManifest(@JsonProperty("assets") List<Entry> assets) {
record Entry(
@JsonProperty("routePath") String routePath,
@JsonProperty("resourcePath") String resourcePath,
@JsonProperty("mimeType") String mimeType,
@JsonProperty("etag") String etag,
@JsonProperty("immutable") boolean immutable
) {
}
}
@@ -0,0 +1,91 @@
package dev.relism.ext.webbundler;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
public final class ClasspathAssetsSource implements AssetsSource {
private static final ObjectMapper JSON = new ObjectMapper();
private final String rootPrefix;
private final String manifestResource;
private ClasspathAssetsSource(String rootPrefix, String manifestResource) {
this.rootPrefix = AssetPaths.normalizePrefix(rootPrefix);
this.manifestResource = AssetIo.stripLeadingSlash(manifestResource);
}
public static ClasspathAssetsSource of(String rootPrefix) {
if (rootPrefix == null || rootPrefix.isBlank()) {
throw new IllegalArgumentException("rootPrefix cannot be blank");
}
String normalized = AssetPaths.normalizePrefix(rootPrefix);
return new ClasspathAssetsSource(normalized, normalized + "/asset-manifest.json");
}
public static ClasspathAssetsSource of(String rootPrefix, String manifestResource) {
if (rootPrefix == null || rootPrefix.isBlank()) {
throw new IllegalArgumentException("rootPrefix cannot be blank");
}
if (manifestResource == null || manifestResource.isBlank()) {
throw new IllegalArgumentException("manifestResource cannot be blank");
}
return new ClasspathAssetsSource(rootPrefix, manifestResource);
}
@Override
public AssetCatalog load(AssetLoadRequest request) {
ClasspathAssetManifest manifest = readManifest();
if (manifest.assets() == null || manifest.assets().isEmpty()) {
throw new IllegalStateException("Classpath asset manifest has no assets: " + manifestResource);
}
Map<String, AssetEntry> byRoute = new HashMap<>();
for (ClasspathAssetManifest.Entry entry : manifest.assets()) {
AssetMetadata meta = toMeta(entry);
byte[] raw = AssetIo.readClasspath(resourcePath(meta.logicalPath()));
byte[] br = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".br"));
byte[] gz = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".gz"));
String routePath = AssetPaths.joinBase(request.basePath(), meta.routePath());
byRoute.put(routePath, new AssetEntry(raw, br, gz, meta.etag(), meta.mimeType(), meta.immutable()));
}
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
AssetEntry index = byRoute.get(indexRoute);
if (index == null) {
throw new IllegalStateException("Missing SPA fallback file in classpath manifest: " + indexRoute);
}
return new AssetCatalog(byRoute, index);
}
private ClasspathAssetManifest readManifest() {
try (InputStream in = ClasspathAssetsSource.class.getClassLoader().getResourceAsStream(manifestResource)) {
if (in == null) throw new IllegalStateException("Missing classpath manifest: " + manifestResource);
String json = new String(in.readAllBytes(), StandardCharsets.UTF_8);
return JSON.readValue(json, ClasspathAssetManifest.class);
} catch (IOException e) {
throw new IllegalStateException("Invalid classpath manifest: " + manifestResource, e);
}
}
private AssetMetadata toMeta(ClasspathAssetManifest.Entry entry) {
if (entry == null) throw new IllegalStateException("Manifest entry cannot be null");
String routePath = AssetIo.normalizePath(entry.routePath());
String logicalPath = entry.resourcePath();
if (logicalPath == null || logicalPath.isBlank()) {
throw new IllegalStateException("Manifest entry resourcePath is required");
}
return new AssetMetadata(routePath, logicalPath.trim(), entry.mimeType(), entry.etag(), entry.immutable());
}
private String resourcePath(String logicalPath) {
if (logicalPath.startsWith("/")) {
return logicalPath.substring(1);
}
return AssetPaths.classpathJoin(rootPrefix, logicalPath);
}
}
@@ -0,0 +1,187 @@
package dev.relism.ext.webbundler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
final class CommandOrchestrator implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(CommandOrchestrator.class);
private final WebBundlerConfig config;
private final CommandSafetyPolicy safetyPolicy;
private final ExecutorService logPool = Executors.newCachedThreadPool(daemonThreadFactory("flash-web-bundler-log"));
private Process devProcess;
CommandOrchestrator(WebBundlerConfig config, CommandSafetyPolicy safetyPolicy) {
this.config = config;
this.safetyPolicy = safetyPolicy;
}
void runBlocking(List<String> command, Path cwd) {
safetyPolicy.check(command);
Process process = start(command, cwd);
try {
int code = process.waitFor();
if (code != 0) {
throw new IllegalStateException("Command failed (" + code + "): " + String.join(" ", command));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting process", e);
}
}
void startDevServer(List<String> command, Path cwd) {
if (devProcess != null && devProcess.isAlive()) return;
if (isPortOccupied(config.devHost(), config.devPort())) {
throw new IllegalStateException("Dev server port already occupied: " + config.devHost() + ":" + config.devPort());
}
safetyPolicy.check(command);
devProcess = start(command, cwd);
}
void healthcheck(String healthPath, Duration timeout) {
long deadline = System.currentTimeMillis() + timeout.toMillis();
String url = "http://" + config.devHost() + ":" + config.devPort() + healthPath;
while (System.currentTimeMillis() < deadline) {
if (devProcess == null || !devProcess.isAlive()) {
throw new IllegalStateException("Dev process exited before becoming healthy");
}
if (ping(url)) return;
sleep(200L);
}
throw new IllegalStateException("Dev server healthcheck timeout for " + url);
}
void restartDevServer(List<String> command, Path cwd) {
stopDevServer();
startDevServer(command, cwd);
}
void stopDevServer() {
if (devProcess == null) return;
destroyProcessTree(devProcess, 5);
devProcess = null;
}
@Override
public void close() {
stopDevServer();
logPool.shutdownNow();
}
private Process start(List<String> command, Path cwd) {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(cwd.toFile());
try {
Process process = pb.start();
attachLogs(process, command.get(0));
return process;
} catch (IOException e) {
throw new IllegalStateException("Failed to launch process: " + String.join(" ", command), e);
}
}
private void attachLogs(Process process, String name) {
if (config.loggingMode() == LoggingMode.QUIET) return;
boolean merge = config.loggingMode() == LoggingMode.MERGED || config.loggingMode() == LoggingMode.VERBOSE;
if (merge) {
logPool.submit(() -> readStream(process.getInputStream(), "[" + name + "] "));
logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + "] "));
} else {
logPool.submit(() -> readStream(process.getInputStream(), "[" + name + ":out] "));
logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + ":err] "));
}
}
private void readStream(java.io.InputStream in, String prefix) {
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
if (config.loggingMode() == LoggingMode.VERBOSE) log.info("{}{}", prefix, line);
else log.debug("{}{}", prefix, line);
}
} catch (IOException ignored) {
}
}
private static boolean ping(String rawUrl) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(rawUrl).openConnection();
conn.setConnectTimeout(500);
conn.setReadTimeout(500);
conn.setRequestMethod("GET");
int code = conn.getResponseCode();
return code >= 200 && code < 500;
} catch (IOException e) {
return false;
}
}
private static boolean isPortOccupied(String host, int port) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), 250);
return true;
} catch (IOException e) {
return false;
}
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static ThreadFactory daemonThreadFactory(String namePrefix) {
return runnable -> {
Thread thread = new Thread(runnable, namePrefix);
thread.setDaemon(true);
return thread;
};
}
private static void destroyProcessTree(Process process, int gracefulSeconds) {
ProcessHandle root = process.toHandle();
List<ProcessHandle> descendants = root.descendants().toList();
// First pass: graceful termination for wrapper + children.
for (ProcessHandle child : descendants) child.destroy();
root.destroy();
waitForExit(root, gracefulSeconds);
// Second pass: force-kill anything still alive (important on Windows cmd wrappers).
for (ProcessHandle child : descendants) {
if (child.isAlive()) child.destroyForcibly();
}
if (root.isAlive()) root.destroyForcibly();
waitForExit(root, 2);
}
private static void waitForExit(ProcessHandle handle, int timeoutSeconds) {
try {
handle.onExit().get(timeoutSeconds, TimeUnit.SECONDS);
} catch (Exception ignored) {
// Best-effort shutdown; callers handle remaining state.
}
}
}
@@ -0,0 +1,7 @@
package dev.relism.ext.webbundler;
public enum CommandSafetyMode {
WARN,
BLOCK,
ALLOW
}
@@ -0,0 +1,27 @@
package dev.relism.ext.webbundler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
final class CommandSafetyPolicy {
private static final Logger log = LoggerFactory.getLogger(CommandSafetyPolicy.class);
private final WebBundlerConfig config;
CommandSafetyPolicy(WebBundlerConfig config) {
this.config = config;
}
void check(List<String> command) {
if (command.isEmpty()) throw new IllegalArgumentException("Command cannot be empty");
String binary = command.get(0);
boolean safe = config.safeCommandRegistry().contains(binary);
if (safe || config.commandSafetyMode() == CommandSafetyMode.ALLOW) return;
if (config.commandSafetyMode() == CommandSafetyMode.BLOCK) {
throw new IllegalStateException("Blocked unsafe command: " + String.join(" ", command));
}
log.warn("Executing command outside safe registry: {}", String.join(" ", command));
}
}
@@ -0,0 +1,14 @@
package dev.relism.ext.webbundler;
import java.util.Arrays;
import java.util.List;
final class CommandTokens {
private CommandTokens() {}
static List<String> split(String command) {
return Arrays.stream(command.trim().split("\\s+"))
.filter(s -> !s.isBlank())
.toList();
}
}
@@ -0,0 +1,72 @@
package dev.relism.ext.webbundler;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
public final class FilesystemAssetsSource implements AssetsSource {
private final Path distDir;
private FilesystemAssetsSource(Path distDir) {
this.distDir = distDir.normalize();
}
public static FilesystemAssetsSource of(Path distDir) {
if (distDir == null) throw new IllegalArgumentException("distDir cannot be null");
return new FilesystemAssetsSource(distDir);
}
@Override
public AssetCatalog load(AssetLoadRequest request) {
Path root = request.webRoot().resolve(distDir).normalize();
if (!Files.exists(root)) {
throw new IllegalStateException("distDir does not exist: " + root);
}
Map<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));
}
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
AssetEntry index = byRoute.get(indexRoute);
if (index == null) {
throw new IllegalStateException("Missing SPA fallback file: " + indexRoute + " (from " + root + ")");
}
return new AssetCatalog(byRoute, index);
}
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;
}
}
}
@@ -0,0 +1,12 @@
package dev.relism.ext.webbundler;
import java.util.List;
interface FrontendStrategy {
FrontendType type();
List<String> devCommand(WebBundlerConfig config, PackageManagerAdapter adapter);
List<String> buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter);
default String healthcheckPath() {
return "/";
}
}
@@ -0,0 +1,5 @@
package dev.relism.ext.webbundler;
public enum FrontendType {
VITE
}
@@ -0,0 +1,24 @@
package dev.relism.ext.webbundler;
import java.util.EnumMap;
import java.util.Map;
final class FrontendTypeResolver {
private final Map<FrontendType, FrontendStrategy> strategies = new EnumMap<>(FrontendType.class);
FrontendTypeResolver() {
register(new ViteFrontendStrategy());
}
void register(FrontendStrategy strategy) {
strategies.put(strategy.type(), strategy);
}
FrontendStrategy resolve(FrontendType type) {
FrontendStrategy strategy = strategies.get(type);
if (strategy == null) {
throw new IllegalArgumentException("Unsupported frontend type: " + type);
}
return strategy;
}
}
@@ -0,0 +1,57 @@
package dev.relism.ext.webbundler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
final class InstallCache {
private final Path cacheFile;
InstallCache(Path webRoot) {
this.cacheFile = webRoot.resolve(".flash-web-bundler/install.hash");
}
boolean lockChanged(Path lockfile) {
if (!Files.exists(lockfile)) return true;
String current = digest(lockfile);
String previous = readCached();
return !current.equals(previous);
}
void writeLockHash(Path lockfile) {
if (!Files.exists(lockfile)) return;
try {
Files.createDirectories(cacheFile.getParent());
Files.writeString(cacheFile, digest(lockfile), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("Failed to write install cache hash", e);
}
}
private String readCached() {
if (!Files.exists(cacheFile)) return "";
try {
return Files.readString(cacheFile, StandardCharsets.UTF_8).trim();
} catch (IOException e) {
return "";
}
}
private static String digest(Path file) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] bytes = Files.readAllBytes(file);
byte[] hash = md.digest(bytes);
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException | IOException e) {
throw new IllegalStateException("Unable to hash lockfile " + file, e);
}
}
}
@@ -0,0 +1,6 @@
package dev.relism.ext.webbundler;
public enum InstallPolicy {
AUTO_IF_LOCK_HASH_CHANGED,
NEVER
}
@@ -0,0 +1,8 @@
package dev.relism.ext.webbundler;
public enum LoggingMode {
MERGED,
SEPARATE,
QUIET,
VERBOSE
}
@@ -0,0 +1,30 @@
package dev.relism.ext.webbundler;
import java.util.Map;
final class MimeTypes {
private static final Map<String, String> EXT = Map.ofEntries(
Map.entry("html", "text/html"),
Map.entry("js", "text/javascript"),
Map.entry("css", "text/css"),
Map.entry("json", "application/json"),
Map.entry("svg", "image/svg+xml"),
Map.entry("png", "image/png"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"),
Map.entry("ico", "image/x-icon"),
Map.entry("woff2", "font/woff2"),
Map.entry("ttf", "font/ttf"),
Map.entry("txt", "text/plain")
);
private MimeTypes() {}
static String byPath(String path) {
int idx = path.lastIndexOf('.');
if (idx < 0 || idx == path.length() - 1) return "application/octet-stream";
String ext = path.substring(idx + 1).toLowerCase();
return EXT.getOrDefault(ext, "application/octet-stream");
}
}
@@ -0,0 +1,19 @@
package dev.relism.ext.webbundler;
import dev.relism.Flash;
final class ModeResolver {
RuntimeEnvironment resolve(WebBundlerConfig config) {
return switch (config.runtimeMode()) {
case PROD -> RuntimeEnvironment.PROD;
case AUTODETECT -> Flash.DEV ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD;
case ENV -> fromEnvironment(config.envRuntimeVariable());
};
}
private RuntimeEnvironment fromEnvironment(String key) {
String value = System.getenv(key);
if (value == null || value.isBlank()) return RuntimeEnvironment.PROD;
return "dev".equalsIgnoreCase(value) ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD;
}
}
@@ -0,0 +1,6 @@
package dev.relism.ext.webbundler;
public enum OperationMode {
ORCHESTRATE_ONLY,
MANAGED
}
@@ -0,0 +1,24 @@
package dev.relism.ext.webbundler;
public enum PackageManager {
NPM("npm", "package-lock.json"),
PNPM("pnpm", "pnpm-lock.yaml"),
YARN("yarn", "yarn.lock"),
BUN("bun", "bun.lockb");
private final String binary;
private final String lockfileName;
PackageManager(String binary, String lockfileName) {
this.binary = binary;
this.lockfileName = lockfileName;
}
public String binary() {
return binary;
}
public String lockfileName() {
return lockfileName;
}
}
@@ -0,0 +1,44 @@
package dev.relism.ext.webbundler;
import java.util.List;
final class PackageManagerAdapter {
private final WebBundlerConfig config;
private final boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win");
PackageManagerAdapter(WebBundlerConfig config) {
this.config = config;
}
List<String> installCommand() {
if (config.installCommand() != null) return CommandTokens.split(config.installCommand());
return switch (config.packageManager()) {
case NPM -> List.of(bin("npm"), "install");
case PNPM -> List.of(bin("pnpm"), "install", "--frozen-lockfile");
case YARN -> List.of(bin("yarn"), "install", "--frozen-lockfile");
case BUN -> List.of(bin("bun"), "install", "--frozen-lockfile");
};
}
List<String> devCommand(String host, int port) {
return switch (config.packageManager()) {
case NPM -> List.of(bin("npm"), "run", "dev", "--", "--host", host, "--port", String.valueOf(port));
case PNPM -> List.of(bin("pnpm"), "dev", "--host", host, "--port", String.valueOf(port));
case YARN -> List.of(bin("yarn"), "dev", "--host", host, "--port", String.valueOf(port));
case BUN -> List.of(bin("bun"), "run", "dev", "--host", host, "--port", String.valueOf(port));
};
}
List<String> buildCommand() {
return switch (config.packageManager()) {
case NPM -> List.of(bin("npm"), "run", "build");
case PNPM -> List.of(bin("pnpm"), "build");
case YARN -> List.of(bin("yarn"), "build");
case BUN -> List.of(bin("bun"), "run", "build");
};
}
private String bin(String name) {
return windows ? name + ".cmd" : name;
}
}
@@ -0,0 +1,6 @@
package dev.relism.ext.webbundler;
enum RuntimeEnvironment {
DEV,
PROD
}
@@ -0,0 +1,7 @@
package dev.relism.ext.webbundler;
public enum RuntimeMode {
PROD,
ENV,
AUTODETECT
}
@@ -0,0 +1,17 @@
package dev.relism.ext.webbundler;
import dev.relism.models.Response;
final class SpaFallbackPolicy {
private final byte[] html;
SpaFallbackPolicy(AssetCatalog catalog) {
this.html = catalog.index().raw();
}
void apply(Response response) {
response.type("text/html");
response.header("Cache-Control", "no-cache");
response.body(html);
}
}
@@ -0,0 +1,46 @@
package dev.relism.ext.webbundler;
import dev.relism.http.HttpStatus;
import dev.relism.models.Request;
import dev.relism.models.Response;
final class StaticAssetServingPolicy {
private final AssetCatalog catalog;
StaticAssetServingPolicy(AssetCatalog catalog) {
this.catalog = catalog;
}
boolean serve(Request req, Response res) {
AssetEntry entry = catalog.find(req.path());
if (entry == null) return false;
String inm = req.header("If-None-Match");
if (entry.etag().equals(inm)) {
res.status(HttpStatus.NOT_MODIFIED);
return true;
}
res.type(entry.mimeType());
if (entry.immutable()) {
res.header("Cache-Control", "public, max-age=31536000, immutable");
} else {
res.header("Cache-Control", "no-cache");
}
res.header("ETag", entry.etag());
res.header("Vary", "Accept-Encoding");
if (accepts(req, "br") && entry.br() != null) {
res.header("Content-Encoding", "br");
res.body(entry.br());
} else if (accepts(req, "gzip") && entry.gz() != null) {
res.header("Content-Encoding", "gzip");
res.body(entry.gz());
} else {
res.body(entry.raw());
}
return true;
}
private static boolean accepts(Request req, String encoding) {
String value = req.header("Accept-Encoding");
return value != null && value.contains(encoding);
}
}
@@ -0,0 +1,26 @@
package dev.relism.ext.webbundler;
import java.util.List;
final class ViteFrontendStrategy implements FrontendStrategy {
@Override
public FrontendType type() {
return FrontendType.VITE;
}
@Override
public List<String> devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
if (config.devCommand() != null) {
return CommandTokens.split(config.devCommand());
}
return adapter.devCommand(config.devHost(), config.devPort());
}
@Override
public List<String> buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
if (config.buildCommand() != null) {
return CommandTokens.split(config.buildCommand());
}
return adapter.buildCommand();
}
}
@@ -0,0 +1,43 @@
package dev.relism.ext.webbundler;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
final class WatchList {
private final Path webRoot;
private final Map<Path, Long> lastKnown = new HashMap<>();
WatchList(Path webRoot) {
this.webRoot = webRoot;
}
void initialize(Iterable<String> watchEntries) {
for (String entry : watchEntries) {
Path file = webRoot.resolve(entry).normalize();
lastKnown.put(file, lastModified(file));
}
}
boolean changed() {
for (Map.Entry<Path, Long> entry : lastKnown.entrySet()) {
long current = lastModified(entry.getKey());
if (current != entry.getValue()) {
entry.setValue(current);
return true;
}
}
return false;
}
private static long lastModified(Path path) {
try {
if (!Files.exists(path)) return -1L;
return Files.getLastModifiedTime(path).toMillis();
} catch (IOException e) {
return -1L;
}
}
}
@@ -0,0 +1,210 @@
package dev.relism.ext.webbundler;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class WebBundlerConfig {
private final RuntimeMode runtimeMode;
private final OperationMode operationMode;
private final FrontendType frontendType;
private final PackageManager packageManager;
private final InstallPolicy installPolicy;
private final LoggingMode loggingMode;
private final CommandSafetyMode commandSafetyMode;
private final BasePathEnforcementMode basePathEnforcement;
private final Path webRoot;
private final String basePath;
private final String devHost;
private final int devPort;
private final AssetsSource assetsSource;
private final String indexFile;
private final List<String> watchList;
private final String devCommand;
private final String buildCommand;
private final String installCommand;
private final List<String> safeCommandRegistry;
private final String envRuntimeVariable;
private WebBundlerConfig(Builder b) {
this.runtimeMode = b.runtimeMode;
this.operationMode = b.operationMode;
this.frontendType = b.frontendType;
this.packageManager = b.packageManager;
this.installPolicy = b.installPolicy;
this.loggingMode = b.loggingMode;
this.commandSafetyMode = b.commandSafetyMode;
this.basePathEnforcement = b.basePathEnforcement;
this.webRoot = b.webRoot.normalize();
this.basePath = sanitizeBasePath(b.basePath);
this.devHost = b.devHost;
this.devPort = b.devPort;
this.assetsSource = b.assetsSource;
this.indexFile = b.indexFile;
this.watchList = List.copyOf(b.watchList);
this.devCommand = blankToNull(b.devCommand);
this.buildCommand = blankToNull(b.buildCommand);
this.installCommand = blankToNull(b.installCommand);
this.safeCommandRegistry = List.copyOf(b.safeCommandRegistry);
this.envRuntimeVariable = b.envRuntimeVariable;
validate();
}
public static Builder builder() {
return new Builder();
}
public RuntimeMode runtimeMode() { return runtimeMode; }
public OperationMode operationMode() { return operationMode; }
public FrontendType frontendType() { return frontendType; }
public PackageManager packageManager() { return packageManager; }
public InstallPolicy installPolicy() { return installPolicy; }
public LoggingMode loggingMode() { return loggingMode; }
public CommandSafetyMode commandSafetyMode() { return commandSafetyMode; }
public BasePathEnforcementMode basePathEnforcement() { return basePathEnforcement; }
public Path webRoot() { return webRoot; }
public String basePath() { return basePath; }
public String devHost() { return devHost; }
public int devPort() { return devPort; }
public AssetsSource assetsSource() { return assetsSource; }
public String indexFile() { return indexFile; }
public List<String> watchList() { return watchList; }
public String devCommand() { return devCommand; }
public String buildCommand() { return buildCommand; }
public String installCommand() { return installCommand; }
public List<String> safeCommandRegistry() { return safeCommandRegistry; }
public String envRuntimeVariable() { return envRuntimeVariable; }
public Path expectedLockfile() {
return webRoot.resolve(packageManager.lockfileName());
}
private void validate() {
Objects.requireNonNull(runtimeMode, "runtimeMode");
Objects.requireNonNull(operationMode, "operationMode");
Objects.requireNonNull(frontendType, "frontendType");
Objects.requireNonNull(packageManager, "packageManager");
Objects.requireNonNull(installPolicy, "installPolicy");
Objects.requireNonNull(loggingMode, "loggingMode");
Objects.requireNonNull(commandSafetyMode, "commandSafetyMode");
Objects.requireNonNull(basePathEnforcement, "basePathEnforcement");
Objects.requireNonNull(webRoot, "webRoot");
Objects.requireNonNull(assetsSource, "assetsSource");
Objects.requireNonNull(indexFile, "indexFile");
if (devPort <= 0 || devPort > 65535) {
throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535");
}
if (watchList.isEmpty()) {
throw new IllegalArgumentException("WebBundlerConfig: watchList must not be empty");
}
for (String path : watchList) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries");
}
}
if (!basePath.startsWith("/")) {
throw new IllegalArgumentException("WebBundlerConfig: basePath must start with '/'");
}
}
private static String sanitizeBasePath(String raw) {
if (raw == null || raw.isBlank()) return "/";
String normalized = raw.trim();
if (!normalized.startsWith("/")) normalized = "/" + normalized;
if (normalized.length() > 1 && normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public static final class Builder {
private RuntimeMode runtimeMode = RuntimeMode.AUTODETECT;
private OperationMode operationMode = OperationMode.ORCHESTRATE_ONLY;
private FrontendType frontendType = FrontendType.VITE;
private PackageManager packageManager = PackageManager.NPM;
private InstallPolicy installPolicy = InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED;
private LoggingMode loggingMode = LoggingMode.MERGED;
private CommandSafetyMode commandSafetyMode = CommandSafetyMode.WARN;
private BasePathEnforcementMode basePathEnforcement = BasePathEnforcementMode.WARN_ONLY;
private Path webRoot = Path.of("web");
private String basePath = "/";
private String devHost = "127.0.0.1";
private int devPort = 5173;
private AssetsSource assetsSource = FilesystemAssetsSource.of(Path.of("dist"));
private String indexFile = "index.html";
private List<String> watchList = defaultWatchList(PackageManager.NPM);
private String devCommand;
private String buildCommand;
private String installCommand;
private List<String> safeCommandRegistry = defaultSafeRegistry();
private String envRuntimeVariable = "FLASH_WEB_BUNDLER_MODE";
public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; return this; }
public Builder operationMode(OperationMode operationMode) { this.operationMode = operationMode; return this; }
public Builder frontendType(FrontendType frontendType) { this.frontendType = frontendType; return this; }
public Builder packageManager(PackageManager packageManager) {
this.packageManager = packageManager;
this.watchList = defaultWatchList(packageManager);
return this;
}
public Builder installPolicy(InstallPolicy installPolicy) { this.installPolicy = installPolicy; return this; }
public Builder loggingMode(LoggingMode loggingMode) { this.loggingMode = loggingMode; return this; }
public Builder commandSafetyMode(CommandSafetyMode commandSafetyMode) { this.commandSafetyMode = commandSafetyMode; return this; }
public Builder basePathEnforcement(BasePathEnforcementMode basePathEnforcement) { this.basePathEnforcement = basePathEnforcement; return this; }
public Builder webRoot(Path webRoot) { this.webRoot = webRoot; return this; }
public Builder basePath(String basePath) { this.basePath = basePath; return this; }
public Builder devHost(String devHost) { this.devHost = devHost; return this; }
public Builder devPort(int devPort) { this.devPort = devPort; return this; }
public Builder assetsSource(AssetsSource assetsSource) { this.assetsSource = assetsSource; return this; }
public Builder assetsFromFilesystem(Path distDir) {
this.assetsSource = FilesystemAssetsSource.of(distDir);
return this;
}
public Builder assetsFromClasspath(String rootPrefix) {
this.assetsSource = ClasspathAssetsSource.of(rootPrefix);
return this;
}
public Builder assetsFromClasspath(String rootPrefix, String manifestResource) {
this.assetsSource = ClasspathAssetsSource.of(rootPrefix, manifestResource);
return this;
}
public Builder indexFile(String indexFile) { this.indexFile = indexFile; return this; }
public Builder watchList(List<String> watchList) { this.watchList = new ArrayList<>(watchList); return this; }
public Builder devCommand(String devCommand) { this.devCommand = devCommand; return this; }
public Builder buildCommand(String buildCommand) { this.buildCommand = buildCommand; return this; }
public Builder installCommand(String installCommand) { this.installCommand = installCommand; return this; }
public Builder safeCommandRegistry(List<String> safeCommandRegistry) {
this.safeCommandRegistry = new ArrayList<>(safeCommandRegistry);
return this;
}
public Builder envRuntimeVariable(String envRuntimeVariable) { this.envRuntimeVariable = envRuntimeVariable; return this; }
public WebBundlerConfig build() {
return new WebBundlerConfig(this);
}
private static List<String> defaultWatchList(PackageManager manager) {
return List.of(
"package.json",
manager.lockfileName(),
"vite.config.ts",
"vite.config.js",
"tsconfig.json",
".env",
".env.local"
);
}
private static List<String> defaultSafeRegistry() {
return List.of(
"npm", "pnpm", "yarn", "bun", "npx",
"npm.cmd", "pnpm.cmd", "yarn.cmd", "bun.cmd", "npx.cmd"
);
}
}
}
@@ -0,0 +1,163 @@
package dev.relism.ext.webbundler;
import dev.relism.extension.ExtensionPhase;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
public final class WebBundlerExtension implements FlashExtension {
private static final Logger log = LoggerFactory.getLogger(WebBundlerExtension.class);
private final WebBundlerConfig config;
private final ModeResolver modeResolver = new ModeResolver();
private final FrontendTypeResolver frontendTypeResolver = new FrontendTypeResolver();
private ScheduledExecutorService watchLoop;
private Thread shutdownHook;
@Override
public int priority() {
return ExtensionPhase.LATE.value;
}
public WebBundlerExtension() {
this(WebBundlerConfig.builder().build());
}
public WebBundlerExtension(WebBundlerConfig config) {
this.config = config;
}
@Override
public void provide(FlashContext ctx) {
RuntimeEnvironment environment = modeResolver.resolve(config);
PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config);
FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType());
CommandSafetyPolicy safetyPolicy = new CommandSafetyPolicy(config);
CommandOrchestrator orchestrator = new CommandOrchestrator(config, safetyPolicy);
WatchList watchList = new WatchList(config.webRoot());
watchList.initialize(config.watchList());
StaticAssetServingPolicy servingPolicy = null;
SpaFallbackPolicy fallbackPolicy = null;
try {
if (environment == RuntimeEnvironment.DEV) {
bootstrapDev(strategy, pmAdapter, orchestrator, watchList);
} else {
AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest(
config.basePath(),
config.indexFile(),
environment,
config.webRoot()
));
servingPolicy = new StaticAssetServingPolicy(catalog);
fallbackPolicy = new SpaFallbackPolicy(catalog);
log.info("flash-web-bundler loaded {} assets for production serving", catalog.size());
}
WebBundlerRuntime runtime = new WebBundlerRuntime(
environment, servingPolicy, fallbackPolicy, orchestrator, watchList
);
registerShutdownHook(runtime);
ctx.provide(WebBundlerRuntime.class, runtime);
} catch (RuntimeException ex) {
shutdownResources(orchestrator);
throw ex;
}
}
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
if (runtime.environment() == RuntimeEnvironment.DEV || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
return;
}
String basePath = config.basePath();
String wildcard = "/".equals(basePath) ? "/**" : basePath + "/**";
app.get(wildcard, (req, res) -> {
if (runtime.servingPolicy().serve(req, res)) return null;
runtime.fallbackPolicy().apply(res);
return null;
});
app.head(wildcard, (req, res) -> {
if (runtime.servingPolicy().serve(req, res)) {
res.body(new byte[0]);
return null;
}
runtime.fallbackPolicy().apply(res);
res.body(new byte[0]);
return null;
});
}
private void bootstrapDev(
FrontendStrategy strategy,
PackageManagerAdapter pmAdapter,
CommandOrchestrator orchestrator,
WatchList watchList
) {
validateDevAssetsSource(config);
InstallCache installCache = new InstallCache(config.webRoot());
Path lock = config.expectedLockfile();
if (config.installPolicy() == InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED && installCache.lockChanged(lock)) {
orchestrator.runBlocking(pmAdapter.installCommand(), config.webRoot());
installCache.writeLockHash(lock);
}
List<String> devCommand = strategy.devCommand(config, pmAdapter);
orchestrator.startDevServer(devCommand, config.webRoot());
orchestrator.healthcheck(strategy.healthcheckPath(), Duration.ofSeconds(25));
startWatchLoop(devCommand, orchestrator, watchList);
log.info("flash-web-bundler started dev server at http://{}:{}", config.devHost(), config.devPort());
}
static void validateDevAssetsSource(WebBundlerConfig config) {
if (config.assetsSource() instanceof ClasspathAssetsSource) {
throw new IllegalStateException("Classpath assets source is not supported in DEV mode. Use assetsFromFilesystem(...) for DEV.");
}
}
private void startWatchLoop(List<String> devCommand, CommandOrchestrator orchestrator, WatchList watchList) {
watchLoop = Executors.newSingleThreadScheduledExecutor(daemonThreadFactory("flash-web-bundler-watch"));
watchLoop.scheduleWithFixedDelay(() -> {
if (watchList.changed()) {
log.warn("Watchlist changed. Restarting frontend dev server.");
orchestrator.restartDevServer(devCommand, config.webRoot());
}
}, 1500L, 1500L, TimeUnit.MILLISECONDS);
}
private void registerShutdownHook(WebBundlerRuntime runtime) {
shutdownHook = new Thread(() -> shutdownResources(runtime.orchestrator()), "flash-web-bundler-shutdown");
shutdownHook.setDaemon(true);
Runtime.getRuntime().addShutdownHook(shutdownHook);
}
private void shutdownResources(CommandOrchestrator orchestrator) {
if (watchLoop != null) {
watchLoop.shutdownNow();
watchLoop = null;
}
orchestrator.close();
}
private static ThreadFactory daemonThreadFactory(String namePrefix) {
return runnable -> {
Thread thread = new Thread(runnable, namePrefix);
thread.setDaemon(true);
return thread;
};
}
}
@@ -0,0 +1,29 @@
package dev.relism.ext.webbundler;
final class WebBundlerRuntime {
private final RuntimeEnvironment environment;
private final StaticAssetServingPolicy servingPolicy;
private final SpaFallbackPolicy fallbackPolicy;
private final CommandOrchestrator orchestrator;
private final WatchList watchList;
WebBundlerRuntime(
RuntimeEnvironment environment,
StaticAssetServingPolicy servingPolicy,
SpaFallbackPolicy fallbackPolicy,
CommandOrchestrator orchestrator,
WatchList watchList
) {
this.environment = environment;
this.servingPolicy = servingPolicy;
this.fallbackPolicy = fallbackPolicy;
this.orchestrator = orchestrator;
this.watchList = watchList;
}
RuntimeEnvironment environment() { return environment; }
StaticAssetServingPolicy servingPolicy() { return servingPolicy; }
SpaFallbackPolicy fallbackPolicy() { return fallbackPolicy; }
CommandOrchestrator orchestrator() { return orchestrator; }
WatchList watchList() { return watchList; }
}
@@ -0,0 +1,39 @@
package dev.relism.ext.webbundler;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class AssetsSourceTest {
@TempDir
Path tempDir;
@Test
void filesystemSource_loadsAssetsAndIndex() throws Exception {
Path webRoot = tempDir.resolve("web");
Path dist = webRoot.resolve("dist");
Files.createDirectories(dist);
Files.writeString(dist.resolve("index.html"), "<html>ok</html>");
Files.writeString(dist.resolve("app.abcd1234.js"), "console.log(1)");
AssetsSource source = FilesystemAssetsSource.of(Path.of("dist"));
AssetCatalog catalog = source.load(new AssetLoadRequest("/app", "index.html", RuntimeEnvironment.PROD, webRoot));
assertNotNull(catalog.index());
AssetEntry js = catalog.find("/app/app.abcd1234.js");
assertNotNull(js);
assertTrue(js.immutable());
}
@Test
void classpathSource_missingManifestFailsFast() {
AssetsSource source = ClasspathAssetsSource.of("missing-root");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> source.load(new AssetLoadRequest("/", "index.html", RuntimeEnvironment.PROD, Path.of("."))));
assertTrue(ex.getMessage().contains("manifest"));
}
}
@@ -0,0 +1,30 @@
package dev.relism.ext.webbundler;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CommandSafetyPolicyTest {
@Test
void block_mode_rejectsUnknownBinary() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.commandSafetyMode(CommandSafetyMode.BLOCK)
.safeCommandRegistry(List.of("npm"))
.build();
CommandSafetyPolicy policy = new CommandSafetyPolicy(cfg);
assertThrows(IllegalStateException.class, () -> policy.check(List.of("sh", "-c", "echo nope")));
}
@Test
void allow_mode_permitsUnknownBinary() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.commandSafetyMode(CommandSafetyMode.ALLOW)
.safeCommandRegistry(List.of("npm"))
.build();
CommandSafetyPolicy policy = new CommandSafetyPolicy(cfg);
assertDoesNotThrow(() -> policy.check(List.of("custom", "run")));
}
}
@@ -0,0 +1,29 @@
package dev.relism.ext.webbundler;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InstallCacheTest {
@TempDir
Path tempDir;
@Test
void lockHashChanges_areDetected() throws Exception {
Path lockfile = tempDir.resolve("package-lock.json");
Files.writeString(lockfile, "{\"a\":1}");
InstallCache cache = new InstallCache(tempDir);
assertTrue(cache.lockChanged(lockfile));
cache.writeLockHash(lockfile);
assertFalse(cache.lockChanged(lockfile));
Files.writeString(lockfile, "{\"a\":2}");
assertTrue(cache.lockChanged(lockfile));
}
}
@@ -0,0 +1,17 @@
package dev.relism.ext.webbundler;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ModeResolverTest {
private final ModeResolver resolver = new ModeResolver();
@Test
void prod_mode_isAlwaysProd() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.runtimeMode(RuntimeMode.PROD)
.build();
assertEquals(RuntimeEnvironment.PROD, resolver.resolve(cfg));
}
}
@@ -0,0 +1,18 @@
package dev.relism.ext.webbundler;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PackageManagerAdapterTest {
@Test
void pnpm_hasFrozenInstallCommand() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.packageManager(PackageManager.PNPM)
.build();
PackageManagerAdapter adapter = new PackageManagerAdapter(cfg);
assertTrue(adapter.installCommand().get(0).startsWith("pnpm"));
assertEquals("--frozen-lockfile", adapter.installCommand().get(2));
}
}
@@ -0,0 +1,58 @@
package dev.relism.ext.webbundler;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class WebBundlerConfigTest {
@Test
void defaults_areSane() {
WebBundlerConfig cfg = WebBundlerConfig.builder().build();
assertEquals(RuntimeMode.AUTODETECT, cfg.runtimeMode());
assertEquals(OperationMode.ORCHESTRATE_ONLY, cfg.operationMode());
assertEquals(InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED, cfg.installPolicy());
assertEquals(LoggingMode.MERGED, cfg.loggingMode());
assertEquals(CommandSafetyMode.WARN, cfg.commandSafetyMode());
}
@Test
void packageManager_setsLockfileInWatchList() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.packageManager(PackageManager.PNPM)
.build();
assertTrue(cfg.watchList().contains("pnpm-lock.yaml"));
}
@Test
void basePath_isNormalized() {
WebBundlerConfig cfg = WebBundlerConfig.builder().basePath("app/").build();
assertEquals("/app", cfg.basePath());
}
@Test
void invalidPort_throws() {
assertThrows(IllegalArgumentException.class, () ->
WebBundlerConfig.builder().devPort(0).build());
}
@Test
void assetsFromFilesystem_setsFilesystemSource() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.webRoot(Path.of("frontend"))
.assetsFromFilesystem(Path.of("build"))
.watchList(List.of("package.json"))
.build();
assertTrue(cfg.assetsSource() instanceof FilesystemAssetsSource);
}
@Test
void assetsFromClasspath_setsClasspathSource() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.assetsFromClasspath("web/dist")
.build();
assertTrue(cfg.assetsSource() instanceof ClasspathAssetsSource);
}
}
@@ -0,0 +1,16 @@
package dev.relism.ext.webbundler;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
class WebBundlerExtensionDevGuardTest {
@Test
void classpathSourceInDev_isRejected() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.assetsFromClasspath("web/dist")
.build();
assertThrows(IllegalStateException.class, () -> WebBundlerExtension.validateDevAssetsSource(cfg));
}
}
@@ -0,0 +1,80 @@
package dev.relism.ext.webbundler;
import dev.relism.extension.FlashApp;
import dev.relism.extension.FlashConfiguration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WebBundlerExtensionIntegrationTest {
@TempDir
Path tempDir;
private FlashApp app;
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
@Test
void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() throws Exception {
Path webRoot = tempDir.resolve("web");
Path dist = webRoot.resolve("dist");
Files.createDirectories(dist);
Files.writeString(dist.resolve("index.html"), "<html>spa</html>");
Files.writeString(dist.resolve("app.js"), "console.log('ok');");
int port;
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
WebBundlerConfig config = WebBundlerConfig.builder()
.runtimeMode(RuntimeMode.PROD)
.operationMode(OperationMode.MANAGED)
.webRoot(webRoot)
.assetsFromFilesystem(Path.of("dist"))
.basePath("/app")
.build();
app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build());
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 + "/app/app.js")).GET().build(),
HttpResponse.BodyHandlers.ofString()
);
assertEquals(200, asset.statusCode());
assertTrue(asset.body().contains("console.log"));
HttpResponse<String> fallback = client.send(
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/app/some/client/route")).GET().build(),
HttpResponse.BodyHandlers.ofString()
);
assertEquals(200, fallback.statusCode());
assertTrue(fallback.body().contains("spa"));
}
}