Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8353033cb1 | ||
|
|
fd96d67fca | ||
|
|
003fd6d1f0 | ||
|
|
680bbca8c7 | ||
|
|
580417e952 | ||
|
|
d6c018242f |
Generated
+2
-2
@@ -31,8 +31,8 @@
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
||||
|
||||
@@ -37,7 +37,7 @@ Format: `<type>(<scope>): <short description>`
|
||||
| `ci` | Changes to GitHub Actions workflows |
|
||||
|
||||
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
|
||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`,
|
||||
`ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
|
||||
`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||
| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
|
||||
| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
|
||||
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
|
||||
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
||||
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||||
|
||||
@@ -112,6 +112,8 @@ public abstract class Repository<T, ID> {
|
||||
- `Tx` in the `FlashContext`
|
||||
- `TxManager` in the `FlashContext`
|
||||
- an annotation processor for `@Transactional`
|
||||
- `TxManager.close()` as an `onClose` callback, so stopping the app releases the manager's
|
||||
session factory and connection pool; give the manager a pool you want closed with the app
|
||||
|
||||
This makes the data layer composable with Flash's extension system without global state.
|
||||
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ public final class DataExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onClose(txManager::close);
|
||||
ctx.provide(Tx.class, tx);
|
||||
ctx.provide(TxManager.class, txManager);
|
||||
if (data != null) ctx.provide(Data.class, data);
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
public interface TxManager {
|
||||
public interface TxManager extends AutoCloseable {
|
||||
TxStatus begin(TxDefinition definition);
|
||||
void commit(TxStatus status);
|
||||
void rollback(TxStatus status);
|
||||
|
||||
/** Releases what this manager was built on, its connection pool included. {@link dev.relism.flash.ext.data.DataExtension} calls it when the app stops. */
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
|
||||
+22
@@ -3,6 +3,10 @@ package dev.relism.flash.ext.data.hibernate;
|
||||
import dev.relism.flash.ext.data.core.*;
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
|
||||
import org.hibernate.engine.spi.SessionFactoryImplementor;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -16,6 +20,24 @@ public class HibernateTxManager implements TxManager {
|
||||
this.sf = Objects.requireNonNull(sessionFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the session factory, then the data source it was given ({@code jakarta.persistence.nonJtaDataSource}
|
||||
* or {@code hibernate.connection.datasource}): Hibernate stops a pool it built itself, never one handed to it.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
ConnectionProvider connections = sf.unwrap(SessionFactoryImplementor.class).getServiceRegistry().getService(ConnectionProvider.class);
|
||||
DataSource ds = connections != null && connections.isUnwrappableAs(DataSource.class) ? connections.unwrap(DataSource.class) : null;
|
||||
sf.close();
|
||||
if (ds instanceof AutoCloseable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to close the data source", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TxStatus begin(TxDefinition definition) {
|
||||
return switch (definition.propagation()) {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.relism.flash.ext.data.hibernate;
|
||||
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.cfg.AvailableSettings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.PrintWriter;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class HibernateTxManagerCloseTest {
|
||||
|
||||
/** Stands in for a pool: closeable, and it records being closed. */
|
||||
static final class Pool implements DataSource, AutoCloseable {
|
||||
boolean closed;
|
||||
@Override public Connection getConnection() throws SQLException { return DriverManager.getConnection("jdbc:h2:mem:tx-close;DB_CLOSE_DELAY=-1"); }
|
||||
@Override public Connection getConnection(String user, String password) throws SQLException { return getConnection(); }
|
||||
@Override public void close() { closed = true; }
|
||||
@Override public <T> T unwrap(Class<T> type) { throw new UnsupportedOperationException(); }
|
||||
@Override public boolean isWrapperFor(Class<?> type) { return false; }
|
||||
@Override public PrintWriter getLogWriter() { return null; }
|
||||
@Override public void setLogWriter(PrintWriter out) {}
|
||||
@Override public void setLoginTimeout(int seconds) {}
|
||||
@Override public int getLoginTimeout() { return 0; }
|
||||
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingTheManagerClosesTheDataSourceHibernateWasGiven() {
|
||||
Pool pool = new Pool();
|
||||
SessionFactory sf = new MetadataSources(new StandardServiceRegistryBuilder()
|
||||
.applySetting(AvailableSettings.JAKARTA_NON_JTA_DATASOURCE, pool)
|
||||
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
|
||||
.build()).buildMetadata().buildSessionFactory();
|
||||
|
||||
new HibernateTxManager(sf).close();
|
||||
|
||||
assertTrue(sf.isClosed());
|
||||
assertTrue(pool.closed);
|
||||
}
|
||||
}
|
||||
+12
@@ -17,6 +17,18 @@ public class JdbcTxManager implements TxManager {
|
||||
this.ds = Objects.requireNonNull(ds);
|
||||
}
|
||||
|
||||
/** Closes the data source when it is closeable, as a pool is; a plain {@code DataSource} holds nothing to release. */
|
||||
@Override
|
||||
public void close() {
|
||||
if (ds instanceof AutoCloseable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to close the data source", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TxStatus begin(TxDefinition definition) {
|
||||
return switch (definition.propagation()) {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.data.jdbc;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.relism.flash.ext.data.DataExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcTxManagerCloseTest {
|
||||
|
||||
/** A pool outlives its app unless someone closes it; the data extension does, once requests have drained. */
|
||||
@Test
|
||||
void stoppingTheAppClosesThePool() {
|
||||
HikariDataSource pool = new HikariDataSource();
|
||||
pool.setJdbcUrl(TestDataSource.URL);
|
||||
FlashApp.create(0).install(new DataExtension(new JdbcTxManager(pool))).start().stop().join();
|
||||
assertTrue(pool.isClosed());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-vite-maven-plugin</artifactId>
|
||||
<packaging>maven-plugin</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-vite</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.maven</groupId>
|
||||
<artifactId>maven-plugin-api</artifactId>
|
||||
<version>3.9.9</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.maven.plugin-tools</groupId>
|
||||
<artifactId>maven-plugin-annotations</artifactId>
|
||||
<version>3.15.1</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-plugin-plugin</artifactId>
|
||||
<version>3.15.1</version>
|
||||
<configuration>
|
||||
<goalPrefix>flash-vite</goalPrefix>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.relism.flash.ext.vite.maven;
|
||||
|
||||
import dev.relism.flash.ext.vite.PackageManager;
|
||||
import dev.relism.flash.ext.vite.ViteExtension;
|
||||
import org.apache.maven.plugin.AbstractMojo;
|
||||
import org.apache.maven.plugin.MojoExecutionException;
|
||||
import org.apache.maven.plugins.annotations.LifecyclePhase;
|
||||
import org.apache.maven.plugins.annotations.Mojo;
|
||||
import org.apache.maven.plugins.annotations.Parameter;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* Builds the Vite project and packages the result where {@code ViteExtension} serves it from in
|
||||
* production, with a maximally gzipped {@code .gz} beside each text file, so the server compresses
|
||||
* nothing. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has its
|
||||
* frontend.
|
||||
*/
|
||||
@Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true)
|
||||
public final class BuildMojo extends AbstractMojo {
|
||||
|
||||
/** Formats that are not compressed already; images and woff gain nothing from gzip. */
|
||||
private static final Set<String> COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm");
|
||||
|
||||
/** The Vite project. */
|
||||
@Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
|
||||
File root;
|
||||
|
||||
@Parameter(defaultValue = "${project.build.outputDirectory}", readonly = true, required = true)
|
||||
File classes;
|
||||
|
||||
/** Leaves the frontend out, for a build that only needs the backend. */
|
||||
@Parameter(property = "flash.vite.skip", defaultValue = "false")
|
||||
boolean skip;
|
||||
|
||||
@Override
|
||||
public void execute() throws MojoExecutionException {
|
||||
if (skip) {
|
||||
getLog().info("Skipping the frontend build");
|
||||
return;
|
||||
}
|
||||
Path project = root.toPath();
|
||||
if (!Files.isRegularFile(project.resolve("package.json"))) {
|
||||
throw new MojoExecutionException("No package.json in " + project + ": set <root> to the Vite project.");
|
||||
}
|
||||
PackageManager.Found packages = PackageManager.of(project);
|
||||
run(project, packages.install());
|
||||
run(project, packages.run("build"));
|
||||
Path dist = project.resolve("dist");
|
||||
if (!Files.isRegularFile(dist.resolve("index.html"))) {
|
||||
throw new MojoExecutionException("The build left no dist/index.html in " + project + ".");
|
||||
}
|
||||
Path target = classes.toPath().resolve(ViteExtension.CLASSPATH);
|
||||
try {
|
||||
if (Files.exists(target)) {
|
||||
try (Stream<Path> old = Files.walk(target)) {
|
||||
for (Path path : old.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
|
||||
}
|
||||
}
|
||||
Files.createDirectories(target.getParent());
|
||||
try (Stream<Path> built = Files.walk(dist)) {
|
||||
for (Path from : built.toList()) {
|
||||
Path to = target.resolve(dist.relativize(from).toString());
|
||||
Files.copy(from, to);
|
||||
if (Files.isRegularFile(to)) gzip(to);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e);
|
||||
}
|
||||
getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/");
|
||||
}
|
||||
|
||||
/** Only worth it from 1 KB, and only kept when it is smaller. */
|
||||
private static void gzip(Path file) throws IOException {
|
||||
String name = file.getFileName().toString();
|
||||
if (!COMPRESSIBLE.contains(name.substring(name.lastIndexOf('.') + 1).toLowerCase()) || Files.size(file) < 1024) return;
|
||||
byte[] raw = Files.readAllBytes(file);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2);
|
||||
try (GZIPOutputStream zip = new GZIPOutputStream(out) {{ def.setLevel(Deflater.BEST_COMPRESSION); }}) {
|
||||
zip.write(raw);
|
||||
}
|
||||
if (out.size() < raw.length) Files.write(file.resolveSibling(name + ".gz"), out.toByteArray());
|
||||
}
|
||||
|
||||
private static void run(Path project, List<String> command) throws MojoExecutionException {
|
||||
int exit;
|
||||
try {
|
||||
exit = new ProcessBuilder(command).directory(project.toFile()).inheritIO().start().waitFor();
|
||||
} catch (IOException e) {
|
||||
throw new MojoExecutionException("Cannot run " + command.getFirst() + ": is Node installed and " + command.getFirst() + " on the PATH?", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new MojoExecutionException("Interrupted running " + String.join(" ", command), e);
|
||||
}
|
||||
if (exit != 0) throw new MojoExecutionException(String.join(" ", command) + " failed with " + exit + " (its output is above).");
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.relism.flash.ext.vite.maven;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */
|
||||
class BuildMojoTest {
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
@Test
|
||||
void theBuildLandsWhereTheExtensionServesItFrom() throws Exception {
|
||||
assumeTrue(onPath(), "npm is not on the PATH");
|
||||
Path web = Files.createDirectories(dir.resolve("web"));
|
||||
Files.writeString(web.resolve("package.json"), """
|
||||
{"name":"t","private":true,"scripts":{"build":"node -e \\"const f=require('fs');f.mkdirSync('dist/assets',{recursive:true});f.writeFileSync('dist/index.html','built');f.writeFileSync('dist/assets/a-12345678.js','1');f.writeFileSync('dist/assets/b-12345678.js','x'.repeat(4096))\\""}}
|
||||
""");
|
||||
Path classes = dir.resolve("classes");
|
||||
Files.createDirectories(classes.resolve("flash-vite"));
|
||||
Files.writeString(classes.resolve("flash-vite/stale.js"), "from the last build");
|
||||
|
||||
mojo(web, classes, false).execute();
|
||||
|
||||
assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html")));
|
||||
assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js")));
|
||||
assertFalse(Files.exists(classes.resolve("flash-vite/stale.js")));
|
||||
// Big enough to gzip, and it shrinks; the 1-byte file is left alone.
|
||||
assertTrue(Files.size(classes.resolve("flash-vite/assets/b-12345678.js.gz")) < 4096);
|
||||
assertFalse(Files.exists(classes.resolve("flash-vite/assets/a-12345678.js.gz")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipBuildsNothing() throws Exception {
|
||||
mojo(dir.resolve("nowhere"), dir.resolve("classes"), true).execute();
|
||||
assertFalse(Files.exists(dir.resolve("classes")));
|
||||
}
|
||||
|
||||
private static BuildMojo mojo(Path root, Path classes, boolean skip) {
|
||||
BuildMojo mojo = new BuildMojo();
|
||||
mojo.root = root.toFile();
|
||||
mojo.classes = classes.toFile();
|
||||
mojo.skip = skip;
|
||||
return mojo;
|
||||
}
|
||||
|
||||
private static boolean onPath() {
|
||||
try {
|
||||
return new ProcessBuilder("npm", "--version").start().waitFor() == 0;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
# flash-ext-vite
|
||||
|
||||
A Vite frontend for a Flash app, in two artifacts:
|
||||
|
||||
- **`flash-ext-vite`**, the extension. In DEV it runs Vite's dev server beside the app. Anywhere
|
||||
else it serves the built frontend from the classpath as a single-page app.
|
||||
- **`flash-ext-vite-maven-plugin`**, the build. It builds the frontend during `mvn package` and puts
|
||||
the result where the extension reads it, so the jar runs on its own.
|
||||
|
||||
## Quick start
|
||||
|
||||
A Vite project in `web/` with the template's `dev` and `build` scripts, and:
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new ViteExtension())
|
||||
.get("/api/hello", (req, res) -> "hi")
|
||||
.start();
|
||||
```
|
||||
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-vite-maven-plugin</artifactId>
|
||||
<version>${flash.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals><goal>build</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
That is the whole setup. `FLASH_ENV=dev` gives you Vite with hot reload, and `mvn package` gives you
|
||||
a jar that serves the frontend.
|
||||
|
||||
## Conventions
|
||||
|
||||
These are fixed on purpose: each one is the Vite default, or the thing that keeps the two artifacts
|
||||
in agreement.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Project | a Vite project whose `package.json` has a `dev` and a `build` script |
|
||||
| Build output | `dist/` inside the project (Vite's `build.outDir` default) |
|
||||
| Inside the jar | `flash-vite/` (`ViteExtension.CLASSPATH`) |
|
||||
| Content-hashed files | everything under `assets/` (Vite's `build.assetsDir` default) |
|
||||
| Package manager | the one whose lockfile is nearest at or above the project: `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json`. With none, npm. A project inside a workspace therefore uses the workspace's lockfile. |
|
||||
| Node | on the `PATH`, with the package manager. Nothing is downloaded. |
|
||||
|
||||
## What you can override
|
||||
|
||||
| Extension | Default | |
|
||||
|---|---|---|
|
||||
| `root(Path)` | `web` | The Vite project, relative to the working directory. DEV only. |
|
||||
| `devPort(int)` | `5173` | Vite's port in DEV. |
|
||||
| `basePath(String)` | `/` | Where the frontend is served. Vite's `base` must match it. |
|
||||
| `navigationOnly(boolean)` | `true` | Only browser navigations fall back to `index.html` (see below). `false` gives every `GET` that matches no file the page, as nginx's `try_files` does, which also turns API 404s into the page. Rarely wanted. |
|
||||
|
||||
| Plugin parameter | Default | |
|
||||
|---|---|---|
|
||||
| `root` / `-Dflash.vite.root` | `${project.basedir}/web` | The Vite project. |
|
||||
| `skip` / `-Dflash.vite.skip` | `false` | Leaves the frontend out, for a backend-only build. |
|
||||
|
||||
There is nothing else to set. If a convention above does not fit, that is a change to this module,
|
||||
not a configuration option.
|
||||
|
||||
## DEV
|
||||
|
||||
`Flash.DEV` (`FLASH_ENV=dev` or `-Dflash.env=dev`) decides the mode:
|
||||
|
||||
1. The dependencies are installed with the lockfile pinned (`pnpm install --frozen-lockfile`,
|
||||
`npm ci`, and so on). This is skipped when `node_modules` was already installed from that same
|
||||
lockfile: its hash is kept in `node_modules/.flash-vite`, so deleting `node_modules` resets it.
|
||||
2. The extension runs `<pm> run dev --host 127.0.0.1 --port <devPort> --strictPort` and waits up to
|
||||
30 s for the port to answer. A port already in use, a script that exits, or a timeout fails the
|
||||
boot with the reason. Vite's output is logged at INFO, prefixed `[vite]`.
|
||||
3. While the app runs, a change to the lockfile (`pnpm add …`) reinstalls and restarts Vite. Vite
|
||||
handles changes to its own config itself.
|
||||
4. When the app stops (`ctx.onClose`), Vite and its whole process tree stop with it.
|
||||
|
||||
The extension registers no routes in DEV. The browser opens Vite's port, and Vite forwards the
|
||||
backend's paths to Flash, so `vite.config` needs a `server.proxy` for them:
|
||||
|
||||
```ts
|
||||
server: {
|
||||
proxy: { '^/(api|auth)(/|$)': 'http://localhost:8080' },
|
||||
},
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Anywhere `Flash.DEV` is false, the build is read once at boot from `flash-vite/` on the classpath,
|
||||
whether that is a directory (`target/classes`) or a jar. A missing build fails the boot and names
|
||||
the plugin. Compression already happened in the build, so boot only reads files and hashes the few
|
||||
that revalidate: about 50 ms for a 500-file app. Everything a response carries is prepared at
|
||||
boot, headers included, so serving allocates nothing.
|
||||
|
||||
`GET` and `HEAD` on `basePath/**` answer as follows. Backend routes still win, because Flash
|
||||
prefers specific routes over the wildcard.
|
||||
|
||||
| Request | Answer |
|
||||
|---|---|
|
||||
| `basePath` itself (`/`) | `index.html`, whatever the client accepts |
|
||||
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable`, no `ETag` (it is never asked for again) |
|
||||
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
|
||||
| no such file, and the request is a navigation | `index.html`, so the client-side router takes it |
|
||||
| no such file otherwise (a `fetch` to `/api/typo`, a missing script) | the app's own `404` |
|
||||
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body |
|
||||
| `HEAD` | the `GET` headers, `Content-Length` included, no body |
|
||||
|
||||
A navigation is what a browser sends when a person opens a URL: `Sec-Fetch-Mode: navigate`, which
|
||||
every current browser sets for exactly this purpose, or an `Accept` that names `text/html`, which
|
||||
covers older browsers, crawlers and `curl -H 'Accept: text/html'`. The check reads the header bytes
|
||||
in place and allocates nothing. This is stricter than Vite's own dev-server fallback, which also
|
||||
takes `Accept: */*` and so gives `fetch('/api/typo')` the page. It is the same rule service
|
||||
workers use for their navigation fallback. With `navigationOnly(false)` the check is skipped.
|
||||
|
||||
A `.gz` beside a file is its gzipped form: the plugin writes one for every text file of 1 KB and
|
||||
more, and it is sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
|
||||
`Vary: Accept-Encoding`.
|
||||
|
||||
Known limits:
|
||||
|
||||
- A browser navigating straight to an API path that does not exist gets the app, which shows its
|
||||
own not-found screen. Only an HTML navigation falls back, so API clients always get the 404.
|
||||
- Every file is held in memory, gzip included. That suits an app's own frontend. Large media belongs
|
||||
on a CDN or behind its own route.
|
||||
|
||||
## The plugin
|
||||
|
||||
The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node, and `mvn package`,
|
||||
`verify` and `install` always produce a jar with its frontend. It:
|
||||
|
||||
1. installs the dependencies with the lockfile pinned;
|
||||
2. runs `<pm> run build` in the project;
|
||||
3. replaces `target/classes/flash-vite/` with the project's `dist/`;
|
||||
4. writes a gzip `.gz` at maximum compression beside every html, js, css, json, map, svg, txt,
|
||||
xml, webmanifest, font and wasm file of 1 KB and more, and keeps it only when it is smaller.
|
||||
Images and woff are left alone, because they are compressed already.
|
||||
|
||||
Missing Node or package manager, a failing install or build, or a build that leaves no
|
||||
`dist/index.html` fails the Maven build with the reason. The package manager's own output shows in
|
||||
the Maven log.
|
||||
|
||||
A Docker image therefore needs one build stage with both a JDK and Node. The frontend does not need
|
||||
its own stage.
|
||||
+1
-9
@@ -10,25 +10,17 @@
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-web-bundler</artifactId>
|
||||
<artifactId>flash-ext-vite</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -0,0 +1,210 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.models.PreEncodedHeader;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.FileSystemAlreadyExistsException;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The built frontend, read once from the classpath at boot and served from memory: every byte a
|
||||
* response carries, headers included, is prepared here, so serving allocates nothing. Compression
|
||||
* happened at build time: a {@code .gz} beside a file is its gzipped form.
|
||||
*/
|
||||
final class Assets {
|
||||
|
||||
private static final PreEncodedHeader IMMUTABLE = new PreEncodedHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
private static final PreEncodedHeader REVALIDATE = new PreEncodedHeader("Cache-Control", "no-cache");
|
||||
private static final PreEncodedHeader VARY = new PreEncodedHeader("Vary", "Accept-Encoding");
|
||||
private static final PreEncodedHeader GZIP = new PreEncodedHeader("Content-Encoding", "gzip");
|
||||
|
||||
private static final Map<String, String> TYPES = Map.ofEntries(
|
||||
Map.entry("html", "text/html; charset=utf-8"),
|
||||
Map.entry("js", "text/javascript; charset=utf-8"),
|
||||
Map.entry("mjs", "text/javascript; charset=utf-8"),
|
||||
Map.entry("css", "text/css; charset=utf-8"),
|
||||
Map.entry("json", "application/json"),
|
||||
Map.entry("map", "application/json"),
|
||||
Map.entry("webmanifest", "application/manifest+json"),
|
||||
Map.entry("txt", "text/plain; charset=utf-8"),
|
||||
Map.entry("xml", "application/xml"),
|
||||
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("webp", "image/webp"),
|
||||
Map.entry("avif", "image/avif"),
|
||||
Map.entry("ico", "image/x-icon"),
|
||||
Map.entry("woff", "font/woff"),
|
||||
Map.entry("woff2", "font/woff2"),
|
||||
Map.entry("ttf", "font/ttf"),
|
||||
Map.entry("otf", "font/otf"),
|
||||
Map.entry("wasm", "application/wasm"));
|
||||
|
||||
private static final byte[] HTML = ascii("text/html");
|
||||
private static final byte[] GZIP_TOKEN = ascii("gzip");
|
||||
private static final byte[] Q = ascii("q=");
|
||||
|
||||
record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, byte[] tag) {}
|
||||
|
||||
final Map<String, Asset> byPath;
|
||||
private final Asset index;
|
||||
private final boolean navigationOnly;
|
||||
|
||||
/**
|
||||
* Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under
|
||||
* {@code basePath}; {@code navigationOnly} as in {@link ViteExtension#navigationOnly}.
|
||||
*/
|
||||
Assets(ClassLoader loader, String basePath, boolean navigationOnly) {
|
||||
this.navigationOnly = navigationOnly;
|
||||
URL index = loader.getResource(ViteExtension.CLASSPATH + "/index.html");
|
||||
if (index == null) {
|
||||
throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): "
|
||||
+ "package the application with flash-ext-vite-maven-plugin, or run it with FLASH_ENV=dev.");
|
||||
}
|
||||
String prefix = "/".equals(basePath) ? "" : basePath;
|
||||
Map<String, Asset> assets = new HashMap<>();
|
||||
try {
|
||||
URI uri = index.toURI();
|
||||
FileSystem opened = null;
|
||||
if ("jar".equals(uri.getScheme())) {
|
||||
try {
|
||||
opened = FileSystems.newFileSystem(uri, Map.of());
|
||||
} catch (FileSystemAlreadyExistsException alreadyOpen) {
|
||||
// Path.of below resolves against the one already open.
|
||||
}
|
||||
}
|
||||
Path root = Path.of(uri).getParent();
|
||||
try (Stream<Path> files = Files.walk(root)) {
|
||||
for (Path file : files.filter(Files::isRegularFile).toList()) {
|
||||
String relative = root.relativize(file).toString().replace('\\', '/');
|
||||
if (relative.endsWith(".gz")) continue;
|
||||
Path gzip = file.resolveSibling(file.getFileName() + ".gz");
|
||||
assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file),
|
||||
Files.isRegularFile(gzip) ? Files.readAllBytes(gzip) : null));
|
||||
}
|
||||
} finally {
|
||||
if (opened != null) opened.close();
|
||||
}
|
||||
} catch (IOException | URISyntaxException e) {
|
||||
throw new IllegalStateException("Cannot read the built frontend at " + index, e);
|
||||
}
|
||||
// The root is the app itself, whatever the client accepts.
|
||||
Asset root = assets.get(prefix + "/index.html");
|
||||
assets.put(prefix + "/", root);
|
||||
if (!prefix.isEmpty()) assets.put(prefix, root);
|
||||
this.byPath = Map.copyOf(assets);
|
||||
this.index = root;
|
||||
}
|
||||
|
||||
/**
|
||||
* The asset at the request's path. A path that is none gets {@code index.html} when a browser
|
||||
* navigates to it, so the client-side router takes it; anything else, an API call or a missing
|
||||
* script, gets the app's own 404. Headers are read as bytes, so nothing here allocates.
|
||||
*/
|
||||
void serve(Request req, Response res) {
|
||||
Asset asset = byPath.get(req.path());
|
||||
if (asset == null) {
|
||||
if (navigationOnly && !navigation(req)) throw HttpException.notFound(req.path());
|
||||
asset = index;
|
||||
}
|
||||
res.header(asset.cache);
|
||||
if (asset.etag != null) {
|
||||
res.header(asset.etag);
|
||||
if (indexOf(req.headerView("If-None-Match"), asset.tag, 0) >= 0) {
|
||||
res.status(HttpStatus.NOT_MODIFIED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.type(asset.type);
|
||||
if (asset.gzip != null) {
|
||||
res.header(VARY);
|
||||
if (acceptsGzip(req.headerView("Accept-Encoding"))) {
|
||||
res.header(GZIP).body(asset.gzip);
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.body(asset.raw);
|
||||
}
|
||||
|
||||
/** What browsers send when a person opens a URL: {@code Sec-Fetch-Mode}, or {@code Accept} naming HTML for older clients. */
|
||||
static boolean navigation(Request req) {
|
||||
return req.headerEquals("Sec-Fetch-Mode", "navigate") || indexOf(req.headerView("Accept"), HTML, 0) >= 0;
|
||||
}
|
||||
|
||||
/** Listed in {@code Accept-Encoding}, and not with a q of zero. */
|
||||
static boolean acceptsGzip(ByteView accept) {
|
||||
int at = indexOf(accept, GZIP_TOKEN, 0);
|
||||
if (at < 0) return false;
|
||||
int end = at;
|
||||
while (end < accept.length() && accept.byteAt(end) != ',') end++;
|
||||
int q = indexOf(accept, Q, at);
|
||||
if (q < 0 || q > end) return true;
|
||||
for (int i = q + 2; i < end; i++) {
|
||||
byte c = accept.byteAt(i);
|
||||
if (c >= '1' && c <= '9') return true;
|
||||
if (c != '0' && c != '.') break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where lowercase {@code needle} starts in {@code view} at or after {@code from}, ASCII case
|
||||
* ignored, or -1 (also for a missing header). Folding with {@code | 0x20} leaves digits, quotes
|
||||
* and slashes as they are, which is all the needles here contain besides letters.
|
||||
*/
|
||||
static int indexOf(ByteView view, byte[] needle, int from) {
|
||||
if (view == null) return -1;
|
||||
outer:
|
||||
for (int i = from, last = view.length() - needle.length; i <= last; i++) {
|
||||
for (int j = 0; j < needle.length; j++) {
|
||||
if ((view.byteAt(i + j) | 0x20) != needle[j]) continue outer;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite puts every content-hashed file under {@code assets/}: those never change, so they are
|
||||
* cached for good and never revalidated, and need no ETag. The rest revalidate by one.
|
||||
*/
|
||||
private static Asset asset(String path, byte[] raw, byte[] gzip) {
|
||||
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
|
||||
byte[] type = ascii(TYPES.getOrDefault(extension, "application/octet-stream"));
|
||||
if (path.startsWith("assets/")) return new Asset(raw, gzip, type, IMMUTABLE, null, null);
|
||||
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
|
||||
return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), ascii(tag));
|
||||
}
|
||||
|
||||
private static byte[] ascii(String text) {
|
||||
return text.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] sha1(byte[] raw) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-1").digest(raw);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Vite's dev server as a child process of the app: dependencies installed whenever the lockfile
|
||||
* differs from the one they were installed from, the server restarted when it changes under it,
|
||||
* and the whole process tree gone when the app stops.
|
||||
*/
|
||||
final class DevServer implements AutoCloseable {
|
||||
private static final Logger log = LoggerFactory.getLogger(DevServer.class);
|
||||
|
||||
private final Path root;
|
||||
private final int port;
|
||||
private final PackageManager.Found packages;
|
||||
private final ScheduledExecutorService watch = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofPlatform().daemon().name("flash-vite-watch").factory());
|
||||
private Process process;
|
||||
private FileTime lockfileTime;
|
||||
|
||||
DevServer(Path root, int port) {
|
||||
if (!Files.isRegularFile(root.resolve("package.json"))) {
|
||||
throw new IllegalStateException("No package.json in " + root.toAbsolutePath() + ": point ViteExtension.root(...) at the Vite project.");
|
||||
}
|
||||
this.root = root;
|
||||
this.port = port;
|
||||
this.packages = PackageManager.of(root);
|
||||
lockfileTime = modified();
|
||||
try {
|
||||
install();
|
||||
start();
|
||||
} catch (RuntimeException failed) {
|
||||
close();
|
||||
throw failed;
|
||||
}
|
||||
if (packages.lockfile() != null) watch.scheduleWithFixedDelay(this::restartIfLockfileChanged, 2, 2, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
watch.shutdownNow();
|
||||
stop();
|
||||
}
|
||||
|
||||
private synchronized void restartIfLockfileChanged() {
|
||||
FileTime now = modified();
|
||||
if (now.equals(lockfileTime)) return;
|
||||
lockfileTime = now;
|
||||
log.info("{} changed, reinstalling and restarting Vite", packages.lockfile().getFileName());
|
||||
try {
|
||||
stop();
|
||||
install();
|
||||
start();
|
||||
} catch (RuntimeException failed) {
|
||||
log.error("Vite did not come back; fix the cause and touch the lockfile to retry", failed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Skipped when {@code node_modules} was installed from this very lockfile. */
|
||||
private void install() {
|
||||
Path stamp = root.resolve("node_modules/.flash-vite");
|
||||
String lockfile = packages.lockfile() == null ? "" : sha256(packages.lockfile());
|
||||
try {
|
||||
if (Files.isRegularFile(stamp) && Files.readString(stamp).equals(lockfile)) return;
|
||||
Process install = spawn(packages.install());
|
||||
if (install.waitFor() != 0) throw new IllegalStateException(String.join(" ", packages.install()) + " failed with " + install.exitValue());
|
||||
Files.createDirectories(stamp.getParent());
|
||||
Files.writeString(stamp, lockfile);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted installing the frontend's dependencies", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void start() {
|
||||
if (answers()) throw new IllegalStateException("Port " + port + " is taken: stop what uses it or set ViteExtension.devPort(...).");
|
||||
process = spawn(packages.run("dev", "--host", "127.0.0.1", "--port", String.valueOf(port), "--strictPort"));
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
|
||||
while (!answers()) {
|
||||
if (!process.isAlive()) throw new IllegalStateException("Vite exited with " + process.exitValue() + " before serving on port " + port + " (its output is logged above).");
|
||||
if (System.nanoTime() > deadline) throw new IllegalStateException("Vite did not answer on port " + port + " within 30 seconds.");
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted waiting for Vite", e);
|
||||
}
|
||||
}
|
||||
log.info("Vite is serving http://127.0.0.1:{}", port);
|
||||
}
|
||||
|
||||
/** npm, pnpm and friends are wrappers: their children have to go as well, gracefully first. */
|
||||
private void stop() {
|
||||
if (process == null) return;
|
||||
List<ProcessHandle> tree = process.descendants().toList();
|
||||
tree.forEach(ProcessHandle::destroy);
|
||||
process.destroy();
|
||||
try {
|
||||
process.onExit().get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception gone) {
|
||||
// Forced below.
|
||||
}
|
||||
tree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
|
||||
if (process.isAlive()) process.destroyForcibly();
|
||||
process = null;
|
||||
}
|
||||
|
||||
/** Starts {@code command} in the project, its output logged line by line. */
|
||||
private Process spawn(List<String> command) {
|
||||
Process started;
|
||||
try {
|
||||
started = new ProcessBuilder(command).directory(root.toFile()).redirectErrorStream(true).start();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot run " + command.getFirst() + ": is it installed and on the PATH?", e);
|
||||
}
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try (BufferedReader out = new BufferedReader(new InputStreamReader(started.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
for (String line; (line = out.readLine()) != null; ) log.info("[vite] {}", line);
|
||||
} catch (IOException closed) {
|
||||
// The process ended.
|
||||
}
|
||||
});
|
||||
return started;
|
||||
}
|
||||
|
||||
private boolean answers() {
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private FileTime modified() {
|
||||
try {
|
||||
return packages.lockfile() == null ? FileTime.fromMillis(0) : Files.getLastModifiedTime(packages.lockfile());
|
||||
} catch (IOException e) {
|
||||
return FileTime.fromMillis(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256(Path file) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The package manager a frontend uses, told apart by its lockfile. Shared with the Maven plugin. */
|
||||
public enum PackageManager {
|
||||
PNPM("pnpm-lock.yaml", "install", "--frozen-lockfile"),
|
||||
YARN("yarn.lock", "install", "--frozen-lockfile"),
|
||||
BUN("bun.lock", "install", "--frozen-lockfile"),
|
||||
NPM("package-lock.json", "ci");
|
||||
|
||||
private static final boolean WINDOWS = System.getProperty("os.name", "").startsWith("Windows");
|
||||
|
||||
private final String lockfile;
|
||||
private final String[] install;
|
||||
|
||||
PackageManager(String lockfile, String... install) {
|
||||
this.lockfile = lockfile;
|
||||
this.install = install;
|
||||
}
|
||||
|
||||
/** A package manager and the lockfile it was found by, {@code null} when there is none. */
|
||||
public record Found(PackageManager manager, Path lockfile) {
|
||||
|
||||
/** Installs exactly what the lockfile pins; without one, npm resolves afresh. */
|
||||
public List<String> install() {
|
||||
if (lockfile == null) return List.of(manager.binary(), "install");
|
||||
List<String> command = new ArrayList<>(List.of(manager.binary()));
|
||||
command.addAll(List.of(manager.install));
|
||||
return command;
|
||||
}
|
||||
|
||||
/** Runs a {@code package.json} script, passing {@code args} through to it. */
|
||||
public List<String> run(String script, String... args) {
|
||||
List<String> command = new ArrayList<>(List.of(manager.binary(), "run", script));
|
||||
if (manager == NPM && args.length > 0) command.add("--");
|
||||
command.addAll(List.of(args));
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest lockfile at or above {@code root}, so a project inside a workspace finds the
|
||||
* workspace's. None at all is npm without a lockfile.
|
||||
*/
|
||||
public static Found of(Path root) {
|
||||
for (Path dir = root.toAbsolutePath().normalize(); dir != null; dir = dir.getParent()) {
|
||||
for (PackageManager manager : values()) {
|
||||
Path lockfile = dir.resolve(manager.lockfile);
|
||||
if (Files.isRegularFile(lockfile)) return new Found(manager, lockfile);
|
||||
}
|
||||
}
|
||||
return new Found(NPM, null);
|
||||
}
|
||||
|
||||
private String binary() {
|
||||
String name = name().toLowerCase();
|
||||
return WINDOWS ? name + ".cmd" : name;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import dev.relism.flash.Flash;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* A Vite frontend for a Flash app. In DEV it runs Vite's dev server beside the app, which the
|
||||
* browser talks to and which proxies the backend's paths to Flash. Otherwise it serves the build
|
||||
* that {@code flash-ext-vite-maven-plugin} packaged into the jar, as a single-page app.
|
||||
*/
|
||||
public final class ViteExtension implements FlashExtension {
|
||||
|
||||
/** Where the build lives on the classpath: the Maven plugin puts it there, production reads it from there. */
|
||||
public static final String CLASSPATH = "flash-vite";
|
||||
|
||||
private Path root = Path.of("web");
|
||||
private int devPort = 5173;
|
||||
private String basePath = "/";
|
||||
private boolean navigationOnly = true;
|
||||
|
||||
/** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */
|
||||
public ViteExtension root(Path root) {
|
||||
this.root = root;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Vite's port in DEV, 5173 by default. */
|
||||
public ViteExtension devPort(int devPort) {
|
||||
this.devPort = devPort;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Where the frontend is served from, {@code /} by default; Vite's {@code base} has to match it. */
|
||||
public ViteExtension basePath(String basePath) {
|
||||
String trimmed = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
|
||||
this.basePath = trimmed.isEmpty() ? "/" : trimmed.startsWith("/") ? trimmed : "/" + trimmed;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether only a browser navigation to a path that is no file gets {@code index.html}, {@code
|
||||
* true} by default, so an API call to a missing route gets a 404 rather than the page. {@code
|
||||
* false} serves the page for every such {@code GET}, as nginx's {@code try_files} would.
|
||||
*/
|
||||
public ViteExtension navigationOnly(boolean navigationOnly) {
|
||||
this.navigationOnly = navigationOnly;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
if (Flash.DEV) {
|
||||
ctx.onClose(new DevServer(root, devPort)::close);
|
||||
return;
|
||||
}
|
||||
Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath, navigationOnly);
|
||||
String everything = "/".equals(basePath) ? "/**" : basePath + "/**";
|
||||
ctx.onReady(() -> {
|
||||
app.get(everything, (req, res) -> {
|
||||
assets.serve(req, res);
|
||||
return null;
|
||||
});
|
||||
app.head(everything, (req, res) -> {
|
||||
assets.serve(req, res);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AssetsTest {
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
/** What production runs from: the build inside a jar, not a directory. */
|
||||
@Test
|
||||
void theBuildIsReadFromInsideAJar() throws IOException {
|
||||
Path jar = dir.resolve("app.jar");
|
||||
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) {
|
||||
for (String name : new String[]{"flash-vite/index.html", "flash-vite/assets/a-12345678.css"}) {
|
||||
out.putNextEntry(new JarEntry(name));
|
||||
out.write("x".getBytes());
|
||||
}
|
||||
}
|
||||
try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) {
|
||||
assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/", true).byPath.keySet());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noBuildFailsTheBootNamingThePlugin() throws IOException {
|
||||
try (URLClassLoader empty = new URLClassLoader(new URL[0], null)) {
|
||||
assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/", true)).getMessage().contains("flash-ext-vite-maven-plugin"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void gzipIsAcceptedUnlessRefused() {
|
||||
assertTrue(Assets.acceptsGzip(view("gzip, deflate, br")));
|
||||
assertTrue(Assets.acceptsGzip(view("br;q=1.0, GZIP;q=0.5")));
|
||||
assertFalse(Assets.acceptsGzip(view("gzip;q=0, br")));
|
||||
assertFalse(Assets.acceptsGzip(view("gzip;q=0.000")));
|
||||
assertFalse(Assets.acceptsGzip(view("br")));
|
||||
assertFalse(Assets.acceptsGzip(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchingIgnoresAsciiCaseAndFindsTheFirstMatch() {
|
||||
byte[] html = "text/html".getBytes();
|
||||
assertEquals(0, Assets.indexOf(view("Text/HTML,*/*"), html, 0));
|
||||
assertEquals(12, Assets.indexOf(view("application/text/html"), html, 0));
|
||||
assertEquals(-1, Assets.indexOf(view("text/htm"), html, 0));
|
||||
assertEquals(-1, Assets.indexOf(null, html, 0));
|
||||
}
|
||||
|
||||
private static ByteView view(String text) {
|
||||
byte[] bytes = text.getBytes();
|
||||
return new ByteView() {
|
||||
@Override public int length() { return bytes.length; }
|
||||
@Override public byte byteAt(int i) { return bytes[i]; }
|
||||
};
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/** A real child process: a package.json whose dev script stands in for Vite. Needs npm on the PATH. */
|
||||
class DevServerTest {
|
||||
|
||||
@TempDir
|
||||
Path project;
|
||||
|
||||
@Test
|
||||
void startsTheDevScriptWaitsForItAndTakesItDownOnClose() throws Exception {
|
||||
assumeTrue(onPath("npm"), "npm is not on the PATH");
|
||||
int port;
|
||||
try (ServerSocket free = new ServerSocket(0)) {
|
||||
port = free.getLocalPort();
|
||||
}
|
||||
Files.writeString(project.resolve("package.json"), """
|
||||
{"name":"t","private":true,"scripts":{"dev":"node -e \\"require('http').createServer((q,s)=>s.end('ok')).listen(+process.argv[process.argv.indexOf('--port')+1],'127.0.0.1')\\" --"}}
|
||||
""");
|
||||
DevServer server = new DevServer(project, port);
|
||||
assertTrue(answers(port));
|
||||
assertTrue(Files.isRegularFile(project.resolve("node_modules/.flash-vite")));
|
||||
server.close();
|
||||
assertFalse(answers(port));
|
||||
}
|
||||
|
||||
private static boolean answers(int port) {
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean onPath(String command) {
|
||||
try {
|
||||
return new ProcessBuilder(command, "--version").start().waitFor() == 0;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PackageManagerTest {
|
||||
|
||||
@TempDir
|
||||
Path workspace;
|
||||
|
||||
/** A project inside a workspace installs from the workspace's lockfile; its own one wins when it has one. */
|
||||
@Test
|
||||
void theNearestLockfileDecides() throws IOException {
|
||||
Path project = Files.createDirectories(workspace.resolve("apps/web"));
|
||||
Files.writeString(workspace.resolve("pnpm-lock.yaml"), "");
|
||||
PackageManager.Found found = PackageManager.of(project);
|
||||
assertEquals(PackageManager.PNPM, found.manager());
|
||||
assertTrue(found.install().getFirst().startsWith("pnpm"));
|
||||
assertEquals(List.of("install", "--frozen-lockfile"), found.install().subList(1, 3));
|
||||
|
||||
Files.writeString(project.resolve("package-lock.json"), "");
|
||||
assertEquals(PackageManager.NPM, PackageManager.of(project).manager());
|
||||
assertEquals("ci", PackageManager.of(project).install().get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void npmNeedsTheSeparatorBeforeScriptArguments() {
|
||||
PackageManager.Found npm = new PackageManager.Found(PackageManager.NPM, null);
|
||||
assertEquals(List.of("run", "dev", "--", "--port", "1"), npm.run("dev", "--port", "1").subList(1, 6));
|
||||
assertEquals("install", npm.install().get(1));
|
||||
assertEquals(List.of("run", "dev", "--port", "1"), new PackageManager.Found(PackageManager.PNPM, null).run("dev", "--port", "1").subList(1, 5));
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.relism.flash.ext.vite;
|
||||
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/** The packaged build (src/test/resources/flash-vite) served by a real server, outside DEV. */
|
||||
class ViteExtensionTest {
|
||||
|
||||
private static final String JS = "/assets/app-AbCd1234.js";
|
||||
private static final String NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
||||
|
||||
@RegisterExtension
|
||||
static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension()));
|
||||
|
||||
@RegisterExtension
|
||||
static final FlashTest everything = FlashTest.of(flash -> flash.install(new ViteExtension().navigationOnly(false)));
|
||||
|
||||
@RegisterExtension
|
||||
static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/")));
|
||||
|
||||
@Test
|
||||
void hashedAssetsAreCachedForeverTheRestRevalidates() {
|
||||
app.get(JS).expectStatus(200)
|
||||
.expectHeader("Cache-Control", "public, max-age=31536000, immutable")
|
||||
.expectHeader("Content-Type", "text/javascript; charset=utf-8")
|
||||
.expectBodyContains("built");
|
||||
app.get("/favicon.svg").expectStatus(200).expectHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
|
||||
@Test
|
||||
void gzipIsServedOnlyWhenAccepted() {
|
||||
app.request().header("Accept-Encoding", "br, gzip").get(JS).expectHeader("Content-Encoding", "gzip").expectHeader("Vary", "Accept-Encoding");
|
||||
assertNull(app.request().header("Accept-Encoding", "gzip;q=0").get(JS).header("Content-Encoding"));
|
||||
assertNull(app.get(JS).header("Content-Encoding"));
|
||||
}
|
||||
|
||||
/** A browser navigating gets the app, dots and all; a fetch or a script tag for nothing gets a 404. */
|
||||
@Test
|
||||
void onlyNavigationsFallBackToTheIndex() {
|
||||
for (String route : new String[]{"/content/2", "/users/ada.lovelace"}) {
|
||||
app.request().header("Accept", NAVIGATION).get(route).expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa");
|
||||
}
|
||||
app.request().header("Sec-Fetch-Mode", "navigate").get("/content/2").expectStatus(200).expectBodyContains("spa");
|
||||
app.request().header("Accept", "application/json").get("/api/nope").expectStatus(404);
|
||||
app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404);
|
||||
app.get("/content/2").expectStatus(404);
|
||||
app.get("/").expectStatus(200).expectBodyContains("spa");
|
||||
nested.get("/app").expectStatus(200).expectBodyContains("spa");
|
||||
}
|
||||
|
||||
/** Opted out of the navigation check, every GET that is no file gets the page, as nginx's try_files would. */
|
||||
@Test
|
||||
void withoutTheNavigationCheckEveryMissGetsTheIndex() {
|
||||
everything.request().header("Accept", "application/json").get("/api/nope").expectStatus(200).expectBodyContains("spa");
|
||||
everything.get("/assets/missing.js").expectStatus(200).expectBodyContains("spa");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() {
|
||||
String etag = app.get("/favicon.svg").header("ETag");
|
||||
app.request().header("If-None-Match", etag).get("/favicon.svg").expectStatus(304)
|
||||
.expectHeader("ETag", etag).expectHeader("Cache-Control", "no-cache");
|
||||
assertNull(app.get(JS).header("ETag"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headDescribesTheBodyWithoutSendingIt() {
|
||||
String length = app.get(JS).header("Content-Length");
|
||||
var head = app.request().head(JS).expectStatus(200).expectHeader("Content-Length", length);
|
||||
assertEquals("", head.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBasePathPrefixesEveryRoute() {
|
||||
nested.get("/app" + JS).expectStatus(200);
|
||||
nested.request().header("Accept", NAVIGATION).get("/app/settings").expectStatus(200).expectBodyContains("spa");
|
||||
nested.get(JS).expectStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
console.log("built");
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"/>
|
||||
|
After Width: | Height: | Size: 42 B |
@@ -0,0 +1 @@
|
||||
<!doctype html><html><body><div id="root">spa</div></body></html>
|
||||
@@ -1,24 +0,0 @@
|
||||
# flash-web-bundler
|
||||
|
||||
`flash-web-bundler` integrates frontend tooling lifecycle into Flash with explicit, policy-driven behavior.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new WebBundlerExtension(
|
||||
WebBundlerConfig.builder()
|
||||
.webRoot(Path.of("web"))
|
||||
.basePath("/")
|
||||
.build()
|
||||
))
|
||||
.start();
|
||||
```
|
||||
|
||||
## Operating Model
|
||||
|
||||
- Dev: orchestrates frontend process lifecycle and health checks.
|
||||
- Prod: serves prebuilt assets from `assetsSource` with ETag/cache/compression support.
|
||||
- Route precedence: backend first, SPA fallback second (`GET`/`HEAD`).
|
||||
|
||||
See also: `asset-sources.md`.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Asset Sources
|
||||
|
||||
`flash-web-bundler` uses explicit source objects for production assets.
|
||||
|
||||
Supported sources:
|
||||
|
||||
- `FilesystemAssetsSource.of(Path)`
|
||||
- `ClasspathAssetsSource.of(String rootPrefix)`
|
||||
|
||||
Builder shortcuts:
|
||||
|
||||
- `.assetsFromFilesystem(Path.of("dist"))`
|
||||
- `.assetsFromClasspath("web/dist")`
|
||||
|
||||
Classpath source requires `asset-manifest.json` generated at build time.
|
||||
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:
|
||||
|
||||
- manifest is missing
|
||||
- manifest is invalid
|
||||
- manifest points to missing resources
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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")`.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Configuration
|
||||
|
||||
`WebBundlerConfig` is immutable and built with `WebBundlerConfig.builder()`.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT`
|
||||
- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
|
||||
- `frontendType`: `VITE`, `STATIC` — see `frontend-selection.md`
|
||||
- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
|
||||
- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
|
||||
- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
|
||||
- `commandSafetyMode`: `WARN`, `BLOCK`, `ALLOW`
|
||||
- `webRoot`, `assetsSource`, `basePath`, `devHost`, `devPort`, `watchList`
|
||||
|
||||
Asset source is explicit and object-based:
|
||||
|
||||
- `assetsFromFilesystem(Path.of("dist"))`
|
||||
- `assetsFromClasspath("web/dist")`
|
||||
|
||||
Or by direct source object:
|
||||
|
||||
- `assetsSource(FilesystemAssetsSource.of(Path.of("dist")))`
|
||||
- `assetsSource(ClasspathAssetsSource.of("web/dist"))`
|
||||
|
||||
Validation is fail-fast:
|
||||
|
||||
- invalid `devPort` (only when `frontendType` requires orchestration — skipped for `STATIC`)
|
||||
- blank/invalid watch entries (same — skipped for `STATIC`)
|
||||
- invalid `basePath`
|
||||
|
||||
`frontendType(...)` has side effects on other defaults, same pattern as `packageManager(...)`
|
||||
resetting `watchList`: it also resets `operationMode` (`MANAGED` for `STATIC`, `ORCHESTRATE_ONLY`
|
||||
otherwise) and `assetsSource` (`webRoot` itself for `STATIC`, `webRoot/dist` otherwise). Call
|
||||
`.frontendType(...)` before any explicit `.operationMode(...)`/`.assetsSource(...)`/`.assetsFrom*(...)`
|
||||
override, or the later call wins.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Dev Lifecycle
|
||||
|
||||
Boot flow in dev mode:
|
||||
|
||||
1. Resolve runtime mode.
|
||||
2. Validate configuration and command safety.
|
||||
3. Optionally install dependencies (hash-based lockfile cache).
|
||||
4. Start dev server process.
|
||||
5. Run healthcheck.
|
||||
6. Start watchlist loop and restart dev process when tracked files change.
|
||||
|
||||
Failure conditions are fail-fast:
|
||||
|
||||
- occupied dev port
|
||||
- command launch failure
|
||||
- healthcheck timeout
|
||||
@@ -1,34 +0,0 @@
|
||||
# Frontend Selection
|
||||
|
||||
Frontend integration is explicit through `frontendType`.
|
||||
|
||||
- No heuristic detection in v1.
|
||||
- Deterministic mapping: `FrontendType -> FrontendStrategy`.
|
||||
- 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:
|
||||
|
||||
- register custom `FrontendStrategy` implementations in the resolver.
|
||||
- override default commands per config.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Modes
|
||||
|
||||
## Runtime Mode
|
||||
|
||||
- `PROD`: production static serving only.
|
||||
- `AUTODETECT`: uses `Flash.DEV`.
|
||||
- `ENV`: uses `FLASH_WEB_BUNDLER_MODE` (`dev` => dev mode, else prod).
|
||||
|
||||
## Operation Mode
|
||||
|
||||
- `ORCHESTRATE_ONLY`: only orchestrates dev tooling.
|
||||
- `MANAGED`: enables production serving + SPA fallback routes.
|
||||
|
||||
`FrontendType.STATIC` defaults `operationMode` to `MANAGED` (see `frontend-selection.md`) — `STATIC`
|
||||
has no dev tooling to orchestrate, so `ORCHESTRATE_ONLY` would make the extension a no-op for it.
|
||||
|
||||
## Orchestration
|
||||
|
||||
Whether DEV mode spawns a dev-server process at all is a separate axis from Runtime Mode: it also
|
||||
depends on `frontendType`. `VITE` orchestrates in DEV; `STATIC` never does, in DEV or PROD — it
|
||||
always loads and serves `assetsSource` directly, the same path `VITE` only takes in PROD.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Package Managers
|
||||
|
||||
Package manager selection is explicit via `packageManager`.
|
||||
|
||||
- `NPM` -> `package-lock.json`
|
||||
- `PNPM` -> `pnpm-lock.yaml`
|
||||
- `YARN` -> `yarn.lock`
|
||||
- `BUN` -> `bun.lockb`
|
||||
|
||||
Install policy:
|
||||
|
||||
- `AUTO_IF_LOCK_HASH_CHANGED`: install only when lock hash changes.
|
||||
- `NEVER`: never auto-install.
|
||||
|
||||
Custom commands can override defaults via:
|
||||
|
||||
- `installCommand`
|
||||
- `devCommand`
|
||||
- `buildCommand`
|
||||
@@ -1,13 +0,0 @@
|
||||
# Performance Notes
|
||||
|
||||
Current v1 optimizations:
|
||||
|
||||
- startup preloading of static assets
|
||||
- precomputed ETags and MIME lookup
|
||||
- compressed payload selection based on `Accept-Encoding`
|
||||
- lockfile-hash install gate to avoid redundant installs
|
||||
|
||||
Future performance work:
|
||||
|
||||
- streaming/sliced file serving for large assets
|
||||
- benchmark suite with p50/p95/p99 latency and allocation profiling
|
||||
@@ -1,17 +0,0 @@
|
||||
# Production Serving
|
||||
|
||||
In production mode, `flash-web-bundler` serves only prebuilt assets.
|
||||
|
||||
Behavior:
|
||||
|
||||
- preloads files from `assetsSource` (filesystem or classpath)
|
||||
- resolves MIME by extension
|
||||
- supports precompressed siblings (`.br`, `.gz`)
|
||||
- sets `Cache-Control` and `ETag`
|
||||
- responds `304 Not Modified` when `If-None-Match` matches
|
||||
|
||||
Classpath mode details:
|
||||
|
||||
- reads entries from `asset-manifest.json` in classpath root
|
||||
- validates referenced resources at startup
|
||||
- fails fast in production if manifest or resources are inconsistent
|
||||
@@ -1,10 +0,0 @@
|
||||
# Routing Fallback
|
||||
|
||||
Fallback policy:
|
||||
|
||||
- backend routes always have precedence
|
||||
- fallback route is wildcard under configured `basePath`
|
||||
- fallback applies to `GET` and `HEAD` only
|
||||
- if no static asset matches, serve SPA `indexFile`
|
||||
|
||||
Namespace collisions should be avoided at app design level (for example, keep API under `/api`).
|
||||
@@ -1,11 +0,0 @@
|
||||
# Security Policies
|
||||
|
||||
Command safety is enforced before process execution.
|
||||
|
||||
Modes:
|
||||
|
||||
- `WARN`: allow unknown binary and log warning.
|
||||
- `BLOCK`: reject unknown binary.
|
||||
- `ALLOW`: skip safe-registry checks.
|
||||
|
||||
Safe registry defaults include common package-manager executables and is configurable.
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package dev.relism.flash.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();
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public record AssetEntry(
|
||||
byte[] raw,
|
||||
byte[] br,
|
||||
byte[] gz,
|
||||
String etag,
|
||||
String mimeType,
|
||||
boolean immutable
|
||||
) {
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
package dev.relism.flash.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;
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package dev.relism.flash.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");
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package dev.relism.flash.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");
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package dev.relism.flash.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;
|
||||
}
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public interface AssetsSource {
|
||||
AssetCatalog load(AssetLoadRequest request);
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package dev.relism.flash.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);
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public enum BasePathEnforcementMode {
|
||||
WARN_ONLY,
|
||||
STRICT
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package dev.relism.flash.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
|
||||
) {
|
||||
}
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
package dev.relism.flash.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);
|
||||
}
|
||||
}
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
package dev.relism.flash.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.
|
||||
}
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public enum CommandSafetyMode {
|
||||
WARN,
|
||||
BLOCK,
|
||||
ALLOW
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package dev.relism.flash.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));
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package dev.relism.flash.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();
|
||||
}
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
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, AssetEntry> byRoute = new HashMap<>();
|
||||
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());
|
||||
AssetEntry index = byRoute.get(indexRoute);
|
||||
if (index == null) {
|
||||
throw new IllegalStateException("Missing SPA fallback file: " + indexRoute + " (from " + root + ")");
|
||||
}
|
||||
return new AssetCatalog(byRoute, index);
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package dev.relism.flash.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 "/";
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public enum FrontendType {
|
||||
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;
|
||||
}
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package dev.relism.flash.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());
|
||||
register(new StaticFrontendStrategy());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package dev.relism.flash.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public enum InstallPolicy {
|
||||
AUTO_IF_LOCK_HASH_CHANGED,
|
||||
NEVER
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public enum LoggingMode {
|
||||
MERGED,
|
||||
SEPARATE,
|
||||
QUIET,
|
||||
VERBOSE
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package dev.relism.flash.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");
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import dev.relism.flash.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;
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public enum OperationMode {
|
||||
ORCHESTRATE_ONLY,
|
||||
MANAGED
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package dev.relism.flash.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;
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package dev.relism.flash.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;
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
enum RuntimeEnvironment {
|
||||
DEV,
|
||||
PROD
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
public enum RuntimeMode {
|
||||
PROD,
|
||||
ENV,
|
||||
AUTODETECT
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import dev.relism.flash.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);
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.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);
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package dev.relism.flash.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();
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
package dev.relism.flash.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
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]));
|
||||
}
|
||||
}
|
||||
-223
@@ -1,223 +0,0 @@
|
||||
package dev.relism.flash.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 (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("/")) {
|
||||
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 = defaultAssetsSource(FrontendType.VITE);
|
||||
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;
|
||||
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);
|
||||
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 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",
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
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;
|
||||
|
||||
public WebBundlerExtension() {
|
||||
this(WebBundlerConfig.builder().build());
|
||||
}
|
||||
|
||||
public WebBundlerExtension(WebBundlerConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, 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 && config.frontendType().requiresOrchestration()) {
|
||||
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;
|
||||
}
|
||||
ctx.onReady(() -> {
|
||||
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
|
||||
boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration();
|
||||
if (orchestrated || 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package dev.relism.flash.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; }
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package dev.relism.flash.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"));
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package dev.relism.flash.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")));
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package dev.relism.flash.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));
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package dev.relism.flash.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));
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package dev.relism.flash.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));
|
||||
}
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
package dev.relism.flash.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);
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package dev.relism.flash.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));
|
||||
}
|
||||
}
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Two frontend layouts served by a real server. Each is laid out on disk inside the
|
||||
* application's own configure(), which the harness runs lazily at first access — by then
|
||||
* {@link TempDir} is populated, and a server whose test never runs is never booted.
|
||||
*/
|
||||
class WebBundlerExtensionIntegrationTest {
|
||||
|
||||
@TempDir
|
||||
static Path tempDir;
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest managed = FlashTest.of(app -> {
|
||||
Path webRoot = write(tempDir.resolve("web").resolve("dist"),
|
||||
"index.html", "<html>spa</html>",
|
||||
"app.js", "console.log('ok');").getParent();
|
||||
|
||||
app.install(new WebBundlerExtension(WebBundlerConfig.builder()
|
||||
.runtimeMode(RuntimeMode.PROD)
|
||||
.operationMode(OperationMode.MANAGED)
|
||||
.webRoot(webRoot)
|
||||
.assetsFromFilesystem(Path.of("dist"))
|
||||
.basePath("/app")
|
||||
.build()));
|
||||
app.get("/api/ping", (req, res) -> "pong");
|
||||
});
|
||||
|
||||
// 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.
|
||||
@RegisterExtension
|
||||
static FlashTest staticFrontend = FlashTest.of(app -> {
|
||||
Path webRoot = write(tempDir.resolve("public"),
|
||||
"index.html", "<html>static</html>",
|
||||
"style.css", "body{color:red}");
|
||||
|
||||
app.install(new WebBundlerExtension(WebBundlerConfig.builder()
|
||||
.runtimeMode(RuntimeMode.PROD)
|
||||
.frontendType(FrontendType.STATIC)
|
||||
.webRoot(webRoot)
|
||||
.build()));
|
||||
app.get("/api/ping", (req, res) -> "pong");
|
||||
});
|
||||
|
||||
@Test
|
||||
void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() {
|
||||
managed.get("/api/ping").expectStatus(200).expectBody("pong");
|
||||
managed.get("/app/app.js").expectStatus(200).expectBodyContains("console.log");
|
||||
managed.get("/app/some/client/route").expectStatus(200).expectBodyContains("spa");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticFrontend_servesAssetsWithoutOrchestration() {
|
||||
staticFrontend.get("/api/ping").expectStatus(200).expectBody("pong");
|
||||
staticFrontend.get("/style.css").expectStatus(200).expectBodyContains("color:red");
|
||||
}
|
||||
|
||||
/** Creates {@code directory} and writes the given name/content pairs into it. */
|
||||
private static Path write(Path directory, String... nameThenContent) {
|
||||
try {
|
||||
Files.createDirectories(directory);
|
||||
for (int i = 0; i < nameThenContent.length; i += 2)
|
||||
Files.writeString(directory.resolve(nameThenContent[i]), nameThenContent[i + 1]);
|
||||
return directory;
|
||||
} catch (IOException failure) {
|
||||
throw new UncheckedIOException(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@
|
||||
<module>flash-ext-view-jte</module>
|
||||
<module>flash-ext-view-thymeleaf</module>
|
||||
<module>flash-ext-limiter</module>
|
||||
<module>flash-ext-web-bundler</module>
|
||||
<module>flash-ext-vite</module>
|
||||
<module>flash-ext-vite-maven-plugin</module>
|
||||
<module>flash-ext-mcp</module>
|
||||
<module>flash-ext-validation</module>
|
||||
<module>flash-ext-scheduler</module>
|
||||
@@ -119,7 +120,7 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-web-bundler</artifactId>
|
||||
<artifactId>flash-ext-vite</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -181,6 +181,12 @@ public class Request {
|
||||
*/
|
||||
public String header(String name) { checkActive(); return requestLine.getHeaders().first(name); }
|
||||
|
||||
/**
|
||||
* The first value of header {@code name} as a view over the request's own bytes, or {@code null}:
|
||||
* {@link #header} without the {@code String}, for a hot path. Valid only while the handler runs.
|
||||
*/
|
||||
public ByteView headerView(String name) { checkActive(); return requestLine.getHeaders().view(name); }
|
||||
|
||||
/**
|
||||
* Returns all values of header {@code name} in declaration order.
|
||||
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).
|
||||
|
||||
@@ -171,6 +171,13 @@ public class Response {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** A content type encoded once, typically at boot: the array is kept, not copied, so it must not change. */
|
||||
public Response type(byte[] ct) {
|
||||
checkActive();
|
||||
this.contentType = ct;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Response type(String ct) {
|
||||
checkActive();
|
||||
this.contentType = ct.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Reference in New Issue
Block a user