From d6c018242f8accc50cdba842eaf9c793465ca314 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Tue, 22 Sep 2026 14:02:35 +0000 Subject: [PATCH 1/4] feat(ext-data): close the connection pool when the app stops TxManager is AutoCloseable and releases what it was built on: JdbcTxManager its data source when closeable, HibernateTxManager its session factory and then the data source Hibernate was handed, which Hibernate itself never closes. DataExtension registers the close as an onClose callback, so a stopped app no longer leaves its pool connected. Co-Authored-By: Claude Opus 5 --- .../flash-ext-data-core/docs/README.md | 2 + .../relism/flash/ext/data/DataExtension.java | 1 + .../relism/flash/ext/data/core/TxManager.java | 6 ++- .../data/hibernate/HibernateTxManager.java | 22 +++++++++ .../HibernateTxManagerCloseTest.java | 48 +++++++++++++++++++ .../flash/ext/data/jdbc/JdbcTxManager.java | 12 +++++ .../ext/data/jdbc/JdbcTxManagerCloseTest.java | 20 ++++++++ 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerCloseTest.java create mode 100644 flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerCloseTest.java diff --git a/flash-extensions/flash-ext-data-core/docs/README.md b/flash-extensions/flash-ext-data-core/docs/README.md index df53379..77025c7 100644 --- a/flash-extensions/flash-ext-data-core/docs/README.md +++ b/flash-extensions/flash-ext-data-core/docs/README.md @@ -112,6 +112,8 @@ public abstract class Repository { - `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. diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java index 017308b..870c61b 100644 --- a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java @@ -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); diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java index 6a1b1c4..ac02baa 100644 --- a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java @@ -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(); } diff --git a/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java index 537893c..d0e053b 100644 --- a/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java +++ b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java @@ -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()) { diff --git a/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerCloseTest.java b/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerCloseTest.java new file mode 100644 index 0000000..ae839e3 --- /dev/null +++ b/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerCloseTest.java @@ -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 unwrap(Class 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); + } +} diff --git a/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java b/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java index 08ad9b1..c61595d 100644 --- a/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java +++ b/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java @@ -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()) { diff --git a/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerCloseTest.java b/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerCloseTest.java new file mode 100644 index 0000000..ddf5844 --- /dev/null +++ b/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerCloseTest.java @@ -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()); + } +} -- 2.54.0 From 580417e952ffaf987aa2ef93331dbdd513cb1f2d Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Tue, 22 Sep 2026 15:47:18 +0000 Subject: [PATCH 2/4] feat(ext-vite): replace the web bundler with a Vite extension and its Maven plugin flash-ext-vite runs Vite's dev server in DEV and otherwise serves the build from the classpath, read straight from the directory or jar with no manifest. The Maven plugin flash-ext-vite-maven-plugin builds the frontend at prepare-package and packages it there, so mvn package makes a jar that serves its own frontend and mvn test needs no Node. Three overrides remain (root, devPort, basePath); the package manager is read from the nearest lockfile. Serving fixes what the bundler got wrong: Vite's hashed files under assets/ are cached as immutable instead of revalidated, HEAD reports the real Content-Length, a missing asset is a 404 instead of the index, 304s carry ETag and Cache-Control, and gzip respects q=0 and is prepared at boot. Every response header is pre-encoded, so serving allocates nothing, which is what Response.type(byte[]) is for. Vite stops with the app through onClose, and a lockfile change reinstalls before restarting. The modes, strategies, logging and command-safety options, the asset-source abstraction, the manifest and the Jackson dependency are gone: 1,535 lines of main code become 480, plus 84 for the plugin. Co-Authored-By: Claude Opus 5 --- .idea/encodings.xml | 4 +- AGENTS.md | 2 +- README.md | 2 + .../flash-ext-vite-maven-plugin/pom.xml | 51 ++++ .../flash/ext/vite/maven/BuildMojo.java | 84 +++++++ .../flash/ext/vite/maven/BuildMojoTest.java | 59 +++++ .../flash-ext-vite/docs/README.md | 134 +++++++++++ .../pom.xml | 10 +- .../dev/relism/flash/ext/vite/Assets.java | 182 ++++++++++++++ .../dev/relism/flash/ext/vite/DevServer.java | 173 ++++++++++++++ .../relism/flash/ext/vite/PackageManager.java | 63 +++++ .../relism/flash/ext/vite/ViteExtension.java | 62 +++++ .../dev/relism/flash/ext/vite/AssetsTest.java | 56 +++++ .../relism/flash/ext/vite/DevServerTest.java | 56 +++++ .../flash/ext/vite/PackageManagerTest.java | 41 ++++ .../flash/ext/vite/ViteExtensionTest.java | 70 ++++++ .../flash-vite/assets/app-AbCd1234.js | 100 ++++++++ .../src/test/resources/flash-vite/favicon.svg | 1 + .../src/test/resources/flash-vite/index.html | 1 + .../flash-ext-web-bundler/docs/README.md | 24 -- .../docs/asset-sources.md | 29 --- .../flash-ext-web-bundler/docs/build-time.md | 43 ---- .../docs/configuration.md | 36 --- .../docs/dev-lifecycle.md | 16 -- .../docs/frontend-selection.md | 34 --- .../flash-ext-web-bundler/docs/modes.md | 21 -- .../docs/package-managers.md | 19 -- .../flash-ext-web-bundler/docs/performance.md | 13 - .../docs/prod-serving.md | 17 -- .../docs/routing-fallback.md | 10 - .../docs/security-policies.md | 11 - .../flash/ext/webbundler/AssetCatalog.java | 33 --- .../ext/webbundler/AssetDirectoryScanner.java | 60 ----- .../flash/ext/webbundler/AssetEntry.java | 11 - .../relism/flash/ext/webbundler/AssetIo.java | 93 -------- .../ext/webbundler/AssetLoadRequest.java | 12 - .../flash/ext/webbundler/AssetMetadata.java | 16 -- .../flash/ext/webbundler/AssetPaths.java | 26 -- .../flash/ext/webbundler/AssetsSource.java | 5 - .../flash/ext/webbundler/AssetsSources.java | 16 -- .../webbundler/BasePathEnforcementMode.java | 6 - .../webbundler/ClasspathAssetManifest.java | 16 -- .../ext/webbundler/ClasspathAssetsSource.java | 91 ------- .../ext/webbundler/CommandOrchestrator.java | 187 --------------- .../ext/webbundler/CommandSafetyMode.java | 7 - .../ext/webbundler/CommandSafetyPolicy.java | 27 --- .../flash/ext/webbundler/CommandTokens.java | 14 -- .../webbundler/FilesystemAssetsSource.java | 40 ---- .../ext/webbundler/FrontendStrategy.java | 12 - .../flash/ext/webbundler/FrontendType.java | 17 -- .../ext/webbundler/FrontendTypeResolver.java | 25 -- .../flash/ext/webbundler/InstallCache.java | 57 ----- .../flash/ext/webbundler/InstallPolicy.java | 6 - .../flash/ext/webbundler/LoggingMode.java | 8 - .../flash/ext/webbundler/MimeTypes.java | 30 --- .../flash/ext/webbundler/ModeResolver.java | 19 -- .../flash/ext/webbundler/OperationMode.java | 6 - .../flash/ext/webbundler/PackageManager.java | 24 -- .../ext/webbundler/PackageManagerAdapter.java | 44 ---- .../ext/webbundler/RuntimeEnvironment.java | 6 - .../flash/ext/webbundler/RuntimeMode.java | 7 - .../ext/webbundler/SpaFallbackPolicy.java | 17 -- .../webbundler/StaticAssetServingPolicy.java | 46 ---- .../webbundler/StaticFrontendStrategy.java | 21 -- .../ext/webbundler/ViteFrontendStrategy.java | 26 -- .../flash/ext/webbundler/WatchList.java | 43 ---- .../flash/ext/webbundler/WebBundlerBuild.java | 53 ----- .../ext/webbundler/WebBundlerConfig.java | 223 ------------------ .../ext/webbundler/WebBundlerExtension.java | 156 ------------ .../ext/webbundler/WebBundlerRuntime.java | 29 --- .../ext/webbundler/AssetsSourceTest.java | 39 --- .../webbundler/CommandSafetyPolicyTest.java | 30 --- .../ext/webbundler/InstallCacheTest.java | 29 --- .../ext/webbundler/ModeResolverTest.java | 17 -- .../webbundler/PackageManagerAdapterTest.java | 18 -- .../ext/webbundler/WebBundlerBuildTest.java | 68 ------ .../ext/webbundler/WebBundlerConfigTest.java | 76 ------ .../WebBundlerExtensionDevGuardTest.java | 16 -- .../WebBundlerExtensionIntegrationTest.java | 81 ------- flash-extensions/pom.xml | 5 +- .../dev/relism/flash/models/Response.java | 7 + pom.xml | 2 +- 82 files changed, 1150 insertions(+), 2197 deletions(-) create mode 100644 flash-extensions/flash-ext-vite-maven-plugin/pom.xml create mode 100644 flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java create mode 100644 flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java create mode 100644 flash-extensions/flash-ext-vite/docs/README.md rename flash-extensions/{flash-ext-web-bundler => flash-ext-vite}/pom.xml (75%) create mode 100644 flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java create mode 100644 flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java create mode 100644 flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java create mode 100644 flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java create mode 100644 flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java create mode 100644 flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java create mode 100644 flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java create mode 100644 flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java create mode 100644 flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js create mode 100644 flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg create mode 100644 flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/README.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/asset-sources.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/build-time.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/configuration.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/modes.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/package-managers.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/performance.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/prod-serving.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md delete mode 100644 flash-extensions/flash-ext-web-bundler/docs/security-policies.md delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java delete mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java diff --git a/.idea/encodings.xml b/.idea/encodings.xml index 278fb63..aac5826 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -31,8 +31,8 @@ - - + + diff --git a/AGENTS.md b/AGENTS.md index cb99dad..d949708 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Format: `(): ` | `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`. diff --git a/README.md b/README.md index 6c1c367..a724514 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/flash-extensions/flash-ext-vite-maven-plugin/pom.xml b/flash-extensions/flash-ext-vite-maven-plugin/pom.xml new file mode 100644 index 0000000..908fdb4 --- /dev/null +++ b/flash-extensions/flash-ext-vite-maven-plugin/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-vite-maven-plugin + maven-plugin + + + + dev.relism + flash-ext-vite + + + org.apache.maven + maven-plugin-api + 3.9.9 + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.1 + provided + + + org.junit.jupiter + junit-jupiter + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + 3.15.1 + + flash-vite + + + + + diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java new file mode 100644 index 0000000..aa44a90 --- /dev/null +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java @@ -0,0 +1,84 @@ +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.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.stream.Stream; + +/** + * Builds the Vite project and packages the result where {@code ViteExtension} serves it from in + * production. 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 { + + /** 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 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 old = Files.walk(target)) { + for (Path path : old.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); + } + } + Files.createDirectories(target.getParent()); + try (Stream built = Files.walk(dist)) { + for (Path from : built.toList()) Files.copy(from, target.resolve(dist.relativize(from).toString())); + } + } catch (IOException e) { + throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e); + } + getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/"); + } + + private static void run(Path project, List 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)."); + } +} diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java new file mode 100644 index 0000000..5024766 --- /dev/null +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java @@ -0,0 +1,59 @@ +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.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')\\""}} + """); + 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"))); + } + + @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; + } + } +} diff --git a/flash-extensions/flash-ext-vite/docs/README.md b/flash-extensions/flash-ext-vite/docs/README.md new file mode 100644 index 0000000..a8a91ec --- /dev/null +++ b/flash-extensions/flash-ext-vite/docs/README.md @@ -0,0 +1,134 @@ +# 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 + + dev.relism + flash-ext-vite-maven-plugin + ${flash.version} + + + build + + + +``` + +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. | + +| 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 ` run dev --host 127.0.0.1 --port --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. 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 | +|---|---| +| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable` | +| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` | +| a path with no file extension (`/content/2`) | `index.html`, so the client-side router takes it | +| a path with an extension that matches no file | `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 | + +Text formats of 1 KB and more (html, js, css, json, svg, fonts, wasm, …) are gzipped once at boot +and sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with +`Vary: Accept-Encoding`. Images and woff are sent as they are, because they are compressed already. + +Known limits: + +- A client-side route whose last segment has a dot (`/users/ada.lovelace`) is treated as a missing + file. Keep dots out of client routes, or route them under `basePath` differently. +- 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 ` run build` in the project; +3. replaces `target/classes/flash-vite/` with the project's `dist/`. + +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. diff --git a/flash-extensions/flash-ext-web-bundler/pom.xml b/flash-extensions/flash-ext-vite/pom.xml similarity index 75% rename from flash-extensions/flash-ext-web-bundler/pom.xml rename to flash-extensions/flash-ext-vite/pom.xml index 45ffc5a..fc375a2 100644 --- a/flash-extensions/flash-ext-web-bundler/pom.xml +++ b/flash-extensions/flash-ext-vite/pom.xml @@ -10,25 +10,17 @@ 2.1.0-SNAPSHOT - flash-ext-web-bundler + flash-ext-vite dev.relism flash - - org.projectlombok - lombok - org.slf4j slf4j-api - - com.fasterxml.jackson.core - jackson-databind - org.junit.jupiter junit-jupiter diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java new file mode 100644 index 0000000..09b5730 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java @@ -0,0 +1,182 @@ +package dev.relism.flash.ext.vite; + +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 java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +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.Set; +import java.util.stream.Stream; +import java.util.zip.GZIPOutputStream; + +/** + * 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. + */ +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 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")); + + /** Formats that are not compressed already; the rest (images, woff) gain nothing from gzip. */ + private static final Set COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm"); + + record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {} + + final Map byPath; + private final Asset index; + + /** Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under {@code basePath}. */ + Assets(ClassLoader loader, String basePath) { + 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 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 files = Files.walk(root)) { + for (Path file : files.filter(Files::isRegularFile).toList()) { + String relative = root.relativize(file).toString().replace('\\', '/'); + assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file))); + } + } finally { + if (opened != null) opened.close(); + } + } catch (IOException | URISyntaxException e) { + throw new IllegalStateException("Cannot read the built frontend at " + index, e); + } + this.byPath = Map.copyOf(assets); + this.index = byPath.get(prefix + "/index.html"); + } + + /** + * The asset at the request's path; a path with no file extension is a client-side route and + * gets {@code index.html}, one with an extension that matches nothing is a 404. + */ + void serve(Request req, Response res) { + String path = req.path(); + Asset asset = byPath.get(path); + if (asset == null) { + if (path.lastIndexOf('.') > path.lastIndexOf('/')) { + res.status(HttpStatus.NOT_FOUND); + return; + } + asset = index; + } + res.header(asset.cache).header(asset.etag); + String known = req.header("If-None-Match"); + if (known != null && known.contains(asset.tag)) { + res.status(HttpStatus.NOT_MODIFIED); + return; + } + res.type(asset.type); + if (asset.gzip != null) { + res.header(VARY); + if (acceptsGzip(req.header("Accept-Encoding"))) { + res.header(GZIP).body(asset.gzip); + return; + } + } + res.body(asset.raw); + } + + /** Listed in {@code Accept-Encoding}, and not with a q of zero. */ + static boolean acceptsGzip(String accept) { + if (accept == null) return false; + int at = accept.indexOf("gzip"); + if (at < 0) return false; + int end = accept.indexOf(',', at); + if (end < 0) end = accept.length(); + int q = accept.indexOf("q=", at); + if (q < 0 || q > end) return true; + for (int i = q + 2; i < end; i++) { + char c = accept.charAt(i); + if (c >= '1' && c <= '9') return true; + if (c != '0' && c != '.') break; + } + return false; + } + + /** Vite puts every content-hashed file under {@code assets/}: those never change, the rest revalidate. */ + private static Asset asset(String path, byte[] raw) { + String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase(); + byte[] type = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII); + byte[] gzip = COMPRESSIBLE.contains(extension) && raw.length >= 1024 ? gzip(raw) : null; + String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\""; + return new Asset(raw, gzip != null && gzip.length < raw.length ? gzip : null, type, + path.startsWith("assets/") ? IMMUTABLE : REVALIDATE, new PreEncodedHeader("ETag", tag), tag); + } + + private static byte[] gzip(byte[] raw) { + ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2); + try (GZIPOutputStream zip = new GZIPOutputStream(out)) { + zip.write(raw); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return out.toByteArray(); + } + + private static byte[] sha1(byte[] raw) { + try { + return MessageDigest.getInstance("SHA-1").digest(raw); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java new file mode 100644 index 0000000..85f900d --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java @@ -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 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 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); + } + } +} diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java new file mode 100644 index 0000000..7ab11b5 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java @@ -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 install() { + if (lockfile == null) return List.of(manager.binary(), "install"); + List 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 run(String script, String... args) { + List 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; + } +} diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java new file mode 100644 index 0000000..65f4aa1 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java @@ -0,0 +1,62 @@ +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 = "/"; + + /** 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; + } + + @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); + 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; + }); + }); + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java new file mode 100644 index 0000000..a1a6720 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java @@ -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.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, "/").byPath.keySet()); + } + } + + @Test + void noBuildFailsTheBootNamingThePlugin() throws IOException { + try (URLClassLoader empty = new URLClassLoader(new URL[0], null)) { + assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/")).getMessage().contains("flash-ext-vite-maven-plugin")); + } + } + + @Test + void gzipIsAcceptedUnlessRefused() { + assertTrue(Assets.acceptsGzip("gzip, deflate, br")); + assertTrue(Assets.acceptsGzip("br;q=1.0, gzip;q=0.5")); + assertFalse(Assets.acceptsGzip("gzip;q=0, br")); + assertFalse(Assets.acceptsGzip("gzip;q=0.000")); + assertFalse(Assets.acceptsGzip("br")); + assertFalse(Assets.acceptsGzip(null)); + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java new file mode 100644 index 0000000..5017b81 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java @@ -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; + } + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java new file mode 100644 index 0000000..243efcb --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java @@ -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)); + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java new file mode 100644 index 0000000..2c3a3e5 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java @@ -0,0 +1,70 @@ +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"; + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash.get("/api/before", (req, res) -> "before") + .install(new ViteExtension()).get("/api/ping", (req, res) -> "pong")); + + @RegisterExtension + static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/"))); + + @Test + void backendRoutesWin() { + app.get("/api/ping").expectStatus(200).expectBody("pong"); + app.get("/api/before").expectStatus(200).expectBody("before"); + } + + @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")); + } + + @Test + void clientRoutesGetTheIndexMissingFilesA404() { + app.get("/content/2").expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa"); + app.get("/assets/missing.js").expectStatus(404); + } + + @Test + void anUnchangedAssetIsNotSentAgain() { + String etag = app.get(JS).header("ETag"); + app.request().header("If-None-Match", etag).get(JS).expectStatus(304) + .expectHeader("ETag", etag).expectHeader("Cache-Control", "public, max-age=31536000, immutable"); + } + + @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.get("/app/settings").expectStatus(200).expectBodyContains("spa"); + nested.get(JS).expectStatus(404); + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js new file mode 100644 index 0000000..ac2f8d1 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js @@ -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"); diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg new file mode 100644 index 0000000..0c7be4e --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg @@ -0,0 +1 @@ + diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html new file mode 100644 index 0000000..6d37619 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html @@ -0,0 +1 @@ +
spa
diff --git a/flash-extensions/flash-ext-web-bundler/docs/README.md b/flash-extensions/flash-ext-web-bundler/docs/README.md deleted file mode 100644 index 9e3db83..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/README.md +++ /dev/null @@ -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`. diff --git a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md b/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md deleted file mode 100644 index a0a82bf..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md +++ /dev/null @@ -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 diff --git a/flash-extensions/flash-ext-web-bundler/docs/build-time.md b/flash-extensions/flash-ext-web-bundler/docs/build-time.md deleted file mode 100644 index 8276599..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/build-time.md +++ /dev/null @@ -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 - - org.codehaus.mojo - exec-maven-plugin - - - web-bundler-manifest - process-classes - java - - dev.relism.flash.ext.webbundler.WebBundlerBuild - - ${project.build.outputDirectory}/web/dist - - - - - -``` - -This assumes the built frontend (`web/dist/`, or a static folder) is already copied under -`target/classes/web/dist` by that point — e.g. via `maven-resources-plugin`'s `copy-resources` -goal, or by running the frontend build with an output directory that points there directly. Once -the manifest is written alongside those files, they're just classpath resources: a plain `mvn -package` (or `maven-shade-plugin` for a fat jar) picks them up with no further configuration, and -the app can then be configured with `.assetsFromClasspath("web/dist")`. diff --git a/flash-extensions/flash-ext-web-bundler/docs/configuration.md b/flash-extensions/flash-ext-web-bundler/docs/configuration.md deleted file mode 100644 index 1c8b8f6..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/configuration.md +++ /dev/null @@ -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. diff --git a/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md b/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md deleted file mode 100644 index c86aebf..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md +++ /dev/null @@ -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 diff --git a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md b/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md deleted file mode 100644 index e72fdb4..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md +++ /dev/null @@ -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. diff --git a/flash-extensions/flash-ext-web-bundler/docs/modes.md b/flash-extensions/flash-ext-web-bundler/docs/modes.md deleted file mode 100644 index 11723b1..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/modes.md +++ /dev/null @@ -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. diff --git a/flash-extensions/flash-ext-web-bundler/docs/package-managers.md b/flash-extensions/flash-ext-web-bundler/docs/package-managers.md deleted file mode 100644 index de3a7e6..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/package-managers.md +++ /dev/null @@ -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` diff --git a/flash-extensions/flash-ext-web-bundler/docs/performance.md b/flash-extensions/flash-ext-web-bundler/docs/performance.md deleted file mode 100644 index 84ce194..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/performance.md +++ /dev/null @@ -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 diff --git a/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md b/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md deleted file mode 100644 index 71db8bc..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md +++ /dev/null @@ -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 diff --git a/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md b/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md deleted file mode 100644 index 943762e..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md +++ /dev/null @@ -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`). diff --git a/flash-extensions/flash-ext-web-bundler/docs/security-policies.md b/flash-extensions/flash-ext-web-bundler/docs/security-policies.md deleted file mode 100644 index 5a06ccf..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/security-policies.md +++ /dev/null @@ -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. diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java deleted file mode 100644 index d5dd346..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.Map; - -public final class AssetCatalog { - private final Map byRoutePath; - private final AssetEntry index; - - AssetCatalog(Map 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(); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java deleted file mode 100644 index 088198b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java +++ /dev/null @@ -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 scan(Path root) { - Map builders = new HashMap<>(); - try (var walk = Files.walk(root)) { - walk.filter(Files::isRegularFile).forEach(file -> { - String rel = "/" + root.relativize(file).toString().replace('\\', '/'); - String canonical = AssetIo.stripBrGzSuffix(rel); - Builder b = builders.computeIfAbsent(canonical, Builder::new); - byte[] bytes = AssetIo.read(file); - if (rel.endsWith(".br")) b.br = bytes; - else if (rel.endsWith(".gz")) b.gz = bytes; - else b.raw = bytes; - }); - } catch (IOException e) { - throw new IllegalStateException("Failed to scan assets from " + root, e); - } - - List result = new ArrayList<>(); - for (Builder b : builders.values()) { - if (b.raw == null) continue; - String etag = AssetIo.quotedSha1(b.raw); - String mime = MimeTypes.byPath(b.canonicalPath); - boolean immutable = AssetIo.isFingerprinted(b.canonicalPath); - result.add(new ScannedAsset(b.canonicalPath, b.raw, b.br, b.gz, etag, mime, immutable)); - } - return result; - } - - private static final class Builder { - private final String canonicalPath; - private byte[] raw; - private byte[] br; - private byte[] gz; - - private Builder(String canonicalPath) { - this.canonicalPath = canonicalPath; - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java deleted file mode 100644 index 46bde2b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java +++ /dev/null @@ -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 -) { -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java deleted file mode 100644 index 3cbaeaa..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java +++ /dev/null @@ -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; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java deleted file mode 100644 index eb88e56..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java +++ /dev/null @@ -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"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java deleted file mode 100644 index 4e8d8a3..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java +++ /dev/null @@ -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"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java deleted file mode 100644 index 7761eac..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java +++ /dev/null @@ -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; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java deleted file mode 100644 index 4660b1e..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java +++ /dev/null @@ -1,5 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public interface AssetsSource { - AssetCatalog load(AssetLoadRequest request); -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java deleted file mode 100644 index 6fe119e..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java +++ /dev/null @@ -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); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java deleted file mode 100644 index b7b8c0d..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum BasePathEnforcementMode { - WARN_ONLY, - STRICT -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java deleted file mode 100644 index 367e468..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java +++ /dev/null @@ -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 assets) { - record Entry( - @JsonProperty("routePath") String routePath, - @JsonProperty("resourcePath") String resourcePath, - @JsonProperty("mimeType") String mimeType, - @JsonProperty("etag") String etag, - @JsonProperty("immutable") boolean immutable - ) { - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java deleted file mode 100644 index 571cd99..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java +++ /dev/null @@ -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 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); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java deleted file mode 100644 index fd1a871..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java +++ /dev/null @@ -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 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 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 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 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 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. - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java deleted file mode 100644 index 61882bd..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum CommandSafetyMode { - WARN, - BLOCK, - ALLOW -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java deleted file mode 100644 index 9169101..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java +++ /dev/null @@ -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 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)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java deleted file mode 100644 index 8f1a11d..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java +++ /dev/null @@ -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 split(String command) { - return Arrays.stream(command.trim().split("\\s+")) - .filter(s -> !s.isBlank()) - .toList(); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java deleted file mode 100644 index 769c07b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java +++ /dev/null @@ -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 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); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java deleted file mode 100644 index 691047b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.List; - -interface FrontendStrategy { - FrontendType type(); - List devCommand(WebBundlerConfig config, PackageManagerAdapter adapter); - List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter); - default String healthcheckPath() { - return "/"; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java deleted file mode 100644 index c980410..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java +++ /dev/null @@ -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; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java deleted file mode 100644 index 05b39da..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.EnumMap; -import java.util.Map; - -final class FrontendTypeResolver { - private final Map 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; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java deleted file mode 100644 index 9241631..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java +++ /dev/null @@ -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); - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java deleted file mode 100644 index e271cda..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum InstallPolicy { - AUTO_IF_LOCK_HASH_CHANGED, - NEVER -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java deleted file mode 100644 index 3134e89..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum LoggingMode { - MERGED, - SEPARATE, - QUIET, - VERBOSE -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java deleted file mode 100644 index 75db7f2..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.Map; - -final class MimeTypes { - private static final Map 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"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java deleted file mode 100644 index 4f04904..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java +++ /dev/null @@ -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; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java deleted file mode 100644 index 7d4cfa8..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum OperationMode { - ORCHESTRATE_ONLY, - MANAGED -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java deleted file mode 100644 index 0199419..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java +++ /dev/null @@ -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; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java deleted file mode 100644 index 53bb798..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java +++ /dev/null @@ -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 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 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 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; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java deleted file mode 100644 index 7050be5..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -enum RuntimeEnvironment { - DEV, - PROD -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java deleted file mode 100644 index bda8e72..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum RuntimeMode { - PROD, - ENV, - AUTODETECT -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java deleted file mode 100644 index 0f8f799..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java +++ /dev/null @@ -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); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java deleted file mode 100644 index 105616b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java +++ /dev/null @@ -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); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java deleted file mode 100644 index ed34e83..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java +++ /dev/null @@ -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 devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - throw new UnsupportedOperationException("STATIC frontend type has no dev command"); - } - - @Override - public List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - throw new UnsupportedOperationException("STATIC frontend type has no build command"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java deleted file mode 100644 index e49cbe7..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java +++ /dev/null @@ -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 devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - if (config.devCommand() != null) { - return CommandTokens.split(config.devCommand()); - } - return adapter.devCommand(config.devHost(), config.devPort()); - } - - @Override - public List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - if (config.buildCommand() != null) { - return CommandTokens.split(config.buildCommand()); - } - return adapter.buildCommand(); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java deleted file mode 100644 index defd640..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java +++ /dev/null @@ -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 lastKnown = new HashMap<>(); - - WatchList(Path webRoot) { - this.webRoot = webRoot; - } - - void initialize(Iterable watchEntries) { - for (String entry : watchEntries) { - Path file = webRoot.resolve(entry).normalize(); - lastKnown.put(file, lastModified(file)); - } - } - - boolean changed() { - for (Map.Entry 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; - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java deleted file mode 100644 index 678323c..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java +++ /dev/null @@ -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 entries = AssetDirectoryScanner.scan(distDir).stream() - .map(asset -> new ClasspathAssetManifest.Entry( - asset.canonicalPath(), - AssetIo.stripLeadingSlash(asset.canonicalPath()), - asset.mimeType(), - asset.etag(), - asset.immutable())) - .toList(); - if (entries.isEmpty()) { - throw new IllegalStateException("No assets found under " + distDir); - } - try { - JSON.writeValue(distDir.resolve("asset-manifest.json").toFile(), new ClasspathAssetManifest(entries)); - } catch (IOException e) { - throw new IllegalStateException("Failed to write asset-manifest.json in " + distDir, e); - } - } - - public static void main(String[] args) { - if (args.length != 1) { - System.err.println("Usage: java " + WebBundlerBuild.class.getName() + " "); - System.exit(1); - } - generateManifest(Path.of(args[0])); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java deleted file mode 100644 index 73415ef..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java +++ /dev/null @@ -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 watchList; - private final String devCommand; - private final String buildCommand; - private final String installCommand; - private final List 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 watchList() { return watchList; } - public String devCommand() { return devCommand; } - public String buildCommand() { return buildCommand; } - public String installCommand() { return installCommand; } - public List 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 watchList = defaultWatchList(PackageManager.NPM); - private String devCommand; - private String buildCommand; - private String installCommand; - private List 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 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 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 defaultWatchList(PackageManager manager) { - return List.of( - "package.json", - manager.lockfileName(), - "vite.config.ts", - "vite.config.js", - "tsconfig.json", - ".env", - ".env.local" - ); - } - - private static List defaultSafeRegistry() { - return List.of( - "npm", "pnpm", "yarn", "bun", "npx", - "npm.cmd", "pnpm.cmd", "yarn.cmd", "bun.cmd", "npx.cmd" - ); - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java deleted file mode 100644 index 3fc1c7c..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java +++ /dev/null @@ -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 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 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; - }; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java deleted file mode 100644 index b825de2..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java +++ /dev/null @@ -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; } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java deleted file mode 100644 index 9ca7522..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java +++ /dev/null @@ -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"), "ok"); - 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")); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java deleted file mode 100644 index 4d7d9a6..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java +++ /dev/null @@ -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"))); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java deleted file mode 100644 index 24f8e53..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java +++ /dev/null @@ -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)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java deleted file mode 100644 index 7b8e248..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java +++ /dev/null @@ -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)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java deleted file mode 100644 index 3cbe93b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java +++ /dev/null @@ -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)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java deleted file mode 100644 index a8ffe8a..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java +++ /dev/null @@ -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"), "built"); - Files.writeString(root.resolve("app.a1b2c3d4.js"), "console.log('built')"); - - WebBundlerBuild.generateManifest(root); - assertTrue(Files.exists(root.resolve("asset-manifest.json"))); - - String rootPrefix = testClasses.relativize(root).toString().replace('\\', '/'); - ClasspathAssetsSource source = ClasspathAssetsSource.of(rootPrefix); - AssetCatalog catalog = source.load(new AssetLoadRequest("/", "index.html", RuntimeEnvironment.PROD, Path.of("."))); - - assertNotNull(catalog.index()); - assertTrue(new String(catalog.index().raw()).contains("built")); - - AssetEntry js = catalog.find("/app.a1b2c3d4.js"); - assertNotNull(js); - assertTrue(js.immutable()); - assertEquals("text/javascript", js.mimeType()); - assertFalse(catalog.index().immutable()); - } - - @Test - void generateManifest_emptyDirectory_throws() throws Exception { - Path testClasses = Path.of(WebBundlerBuildTest.class.getProtectionDomain().getCodeSource().getLocation().toURI()); - root = testClasses.resolve("web-bundler-build-empty-" + System.nanoTime()); - Files.createDirectories(root); - - assertThrows(IllegalStateException.class, () -> WebBundlerBuild.generateManifest(root)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java deleted file mode 100644 index 1e3542c..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java +++ /dev/null @@ -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()); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java deleted file mode 100644 index 45e0c2a..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java +++ /dev/null @@ -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)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java deleted file mode 100644 index 6060fa4..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java +++ /dev/null @@ -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", "spa", - "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", "static", - "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); - } - } -} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index bbbf68b..986162c 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -27,7 +27,8 @@ flash-ext-view-jte flash-ext-view-thymeleaf flash-ext-limiter - flash-ext-web-bundler + flash-ext-vite + flash-ext-vite-maven-plugin flash-ext-mcp flash-ext-validation flash-ext-scheduler @@ -119,7 +120,7 @@
dev.relism - flash-ext-web-bundler + flash-ext-vite ${project.version} diff --git a/flash/src/main/java/dev/relism/flash/models/Response.java b/flash/src/main/java/dev/relism/flash/models/Response.java index 246c1bd..a49d299 100644 --- a/flash/src/main/java/dev/relism/flash/models/Response.java +++ b/flash/src/main/java/dev/relism/flash/models/Response.java @@ -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); diff --git a/pom.xml b/pom.xml index d5546cc..e06dad3 100644 --- a/pom.xml +++ b/pom.xml @@ -136,7 +136,7 @@ dev.relism - flash-ext-web-bundler + flash-ext-vite ${project.version} -- 2.54.0 From 680bbca8c70327094366edffd68fb21cea5def0b Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Tue, 22 Sep 2026 15:54:19 +0000 Subject: [PATCH 3/4] feat(ext-vite): gzip at build time, fall back to the index only for navigations The Maven plugin now writes a maximally compressed .gz beside every text file of 1 KB and more, so the server compresses nothing and boot only reads the files: about 50 ms for Glossa's 500. Hashed files under assets/ skip the ETag, which nothing ever asks for. A path that is no file gets index.html only when the request's Accept names text/html, as a browser navigation does. Everything else, an API call to a missing route included, gets the app's own 404 instead of the index, and a dot in a client route no longer matters. The base path itself always serves the app. Co-Authored-By: Claude Opus 5 --- .../flash/ext/vite/maven/BuildMojo.java | 30 +++++++- .../flash/ext/vite/maven/BuildMojoTest.java | 6 +- .../flash-ext-vite/docs/README.md | 27 ++++--- .../dev/relism/flash/ext/vite/Assets.java | 71 ++++++++---------- .../dev/relism/flash/ext/vite/AssetsTest.java | 2 +- .../flash/ext/vite/ViteExtensionTest.java | 34 +++++---- .../flash-vite/assets/app-AbCd1234.js.gz | Bin 0 -> 61 bytes 7 files changed, 100 insertions(+), 70 deletions(-) create mode 100644 flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java index aa44a90..944ee9b 100644 --- a/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java @@ -8,22 +8,30 @@ 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. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has - * its frontend. + * 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 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; @@ -61,7 +69,11 @@ public final class BuildMojo extends AbstractMojo { } Files.createDirectories(target.getParent()); try (Stream built = Files.walk(dist)) { - for (Path from : built.toList()) Files.copy(from, target.resolve(dist.relativize(from).toString())); + 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); @@ -69,6 +81,18 @@ public final class BuildMojo extends AbstractMojo { 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 command) throws MojoExecutionException { int exit; try { diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java index 5024766..f447882 100644 --- a/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java @@ -9,6 +9,7 @@ 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. */ @@ -22,7 +23,7 @@ class BuildMojoTest { 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')\\""}} + {"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")); @@ -33,6 +34,9 @@ class BuildMojoTest { 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 diff --git a/flash-extensions/flash-ext-vite/docs/README.md b/flash-extensions/flash-ext-vite/docs/README.md index a8a91ec..be3452e 100644 --- a/flash-extensions/flash-ext-vite/docs/README.md +++ b/flash-extensions/flash-ext-vite/docs/README.md @@ -91,29 +91,31 @@ server: { 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. Everything a response carries is prepared at boot, headers included, so serving -allocates nothing. +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 | |---|---| -| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable` | +| `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` | -| a path with no file extension (`/content/2`) | `index.html`, so the client-side router takes it | -| a path with an extension that matches no file | `404` | +| no such file, and `Accept` names `text/html` (a browser navigating to `/content/2`) | `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 | -Text formats of 1 KB and more (html, js, css, json, svg, fonts, wasm, …) are gzipped once at boot -and sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with -`Vary: Accept-Encoding`. Images and woff are sent as they are, because they are compressed already. +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 client-side route whose last segment has a dot (`/users/ada.lovelace`) is treated as a missing - file. Keep dots out of client routes, or route them under `basePath` differently. +- 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. @@ -124,7 +126,10 @@ The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node, 1. installs the dependencies with the lockfile pinned; 2. runs ` run build` in the project; -3. replaces `target/classes/flash-vite/` with the project's `dist/`. +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 diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java index 09b5730..5f34a98 100644 --- a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java @@ -1,13 +1,12 @@ 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 java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UncheckedIOException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -22,13 +21,12 @@ import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.HexFormat; import java.util.Map; -import java.util.Set; import java.util.stream.Stream; -import java.util.zip.GZIPOutputStream; /** * 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. + * 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 { @@ -61,9 +59,6 @@ final class Assets { Map.entry("otf", "font/otf"), Map.entry("wasm", "application/wasm")); - /** Formats that are not compressed already; the rest (images, woff) gain nothing from gzip. */ - private static final Set COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm"); - record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {} final Map byPath; @@ -92,7 +87,10 @@ final class Assets { try (Stream files = Files.walk(root)) { for (Path file : files.filter(Files::isRegularFile).toList()) { String relative = root.relativize(file).toString().replace('\\', '/'); - assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file))); + 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(); @@ -100,29 +98,34 @@ final class Assets { } 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 = byPath.get(prefix + "/index.html"); + this.index = root; } /** - * The asset at the request's path; a path with no file extension is a client-side route and - * gets {@code index.html}, one with an extension that matches nothing is a 404. + * The asset at the request's path. A path that is none is a client-side route when a browser + * navigates to it ({@code Accept} names HTML), so it gets {@code index.html}; for anything + * else, an API call or a missing script, it is the app's own 404. */ void serve(Request req, Response res) { - String path = req.path(); - Asset asset = byPath.get(path); + Asset asset = byPath.get(req.path()); if (asset == null) { - if (path.lastIndexOf('.') > path.lastIndexOf('/')) { - res.status(HttpStatus.NOT_FOUND); - return; - } + String accept = req.header("Accept"); + if (accept == null || !accept.contains("text/html")) throw HttpException.notFound(req.path()); asset = index; } - res.header(asset.cache).header(asset.etag); - String known = req.header("If-None-Match"); - if (known != null && known.contains(asset.tag)) { - res.status(HttpStatus.NOT_MODIFIED); - return; + res.header(asset.cache); + if (asset.etag != null) { + res.header(asset.etag); + String known = req.header("If-None-Match"); + if (known != null && known.contains(asset.tag)) { + res.status(HttpStatus.NOT_MODIFIED); + return; + } } res.type(asset.type); if (asset.gzip != null) { @@ -152,24 +155,16 @@ final class Assets { return false; } - /** Vite puts every content-hashed file under {@code assets/}: those never change, the rest revalidate. */ - private static Asset asset(String path, byte[] raw) { + /** + * 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 = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII); - byte[] gzip = COMPRESSIBLE.contains(extension) && raw.length >= 1024 ? gzip(raw) : null; + 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 != null && gzip.length < raw.length ? gzip : null, type, - path.startsWith("assets/") ? IMMUTABLE : REVALIDATE, new PreEncodedHeader("ETag", tag), tag); - } - - private static byte[] gzip(byte[] raw) { - ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2); - try (GZIPOutputStream zip = new GZIPOutputStream(out)) { - zip.write(raw); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - return out.toByteArray(); + return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), tag); } private static byte[] sha1(byte[] raw) { diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java index a1a6720..980d34e 100644 --- a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java @@ -33,7 +33,7 @@ class AssetsTest { } } try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { - assertEquals(Set.of("/index.html", "/assets/a-12345678.css"), new Assets(loader, "/").byPath.keySet()); + assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/").byPath.keySet()); } } diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java index 2c3a3e5..fe9cd16 100644 --- a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java @@ -11,20 +11,14 @@ import static org.junit.jupiter.api.Assertions.assertNull; 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.get("/api/before", (req, res) -> "before") - .install(new ViteExtension()).get("/api/ping", (req, res) -> "pong")); + static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension())); @RegisterExtension static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/"))); - @Test - void backendRoutesWin() { - app.get("/api/ping").expectStatus(200).expectBody("pong"); - app.get("/api/before").expectStatus(200).expectBody("before"); - } - @Test void hashedAssetsAreCachedForeverTheRestRevalidates() { app.get(JS).expectStatus(200) @@ -41,17 +35,25 @@ class ViteExtensionTest { 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 clientRoutesGetTheIndexMissingFilesA404() { - app.get("/content/2").expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa"); - app.get("/assets/missing.js").expectStatus(404); + 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("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"); } @Test - void anUnchangedAssetIsNotSentAgain() { - String etag = app.get(JS).header("ETag"); - app.request().header("If-None-Match", etag).get(JS).expectStatus(304) - .expectHeader("ETag", etag).expectHeader("Cache-Control", "public, max-age=31536000, immutable"); + 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 @@ -64,7 +66,7 @@ class ViteExtensionTest { @Test void aBasePathPrefixesEveryRoute() { nested.get("/app" + JS).expectStatus(200); - nested.get("/app/settings").expectStatus(200).expectBodyContains("spa"); + nested.request().header("Accept", NAVIGATION).get("/app/settings").expectStatus(200).expectBodyContains("spa"); nested.get(JS).expectStatus(404); } } diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz new file mode 100644 index 0000000000000000000000000000000000000000..65101b6499964ad24388dfd58b58c917e68e8de9 GIT binary patch literal 61 zcmb2|=3tOnvMGz{zxTQG+UL%DUOj!%|9XIz*4Z Date: Tue, 22 Sep 2026 16:16:27 +0000 Subject: [PATCH 4/4] feat(ext-vite): recognise navigations by Sec-Fetch-Mode, read every header in place A path that is no file falls back to index.html when the request is a navigation: Sec-Fetch-Mode: navigate, or an Accept naming text/html for older clients. That check, Accept-Encoding and If-None-Match are all matched on the header bytes through the new Request.headerView(name), so serving still allocates nothing. navigationOnly(false) drops the check for an app that wants every GET miss to get the page. Co-Authored-By: Claude Opus 5 --- .../flash-ext-vite/docs/README.md | 10 ++- .../dev/relism/flash/ext/vite/Assets.java | 73 ++++++++++++++----- .../relism/flash/ext/vite/ViteExtension.java | 13 +++- .../dev/relism/flash/ext/vite/AssetsTest.java | 32 ++++++-- .../flash/ext/vite/ViteExtensionTest.java | 11 +++ .../java/dev/relism/flash/models/Request.java | 6 ++ 6 files changed, 116 insertions(+), 29 deletions(-) diff --git a/flash-extensions/flash-ext-vite/docs/README.md b/flash-extensions/flash-ext-vite/docs/README.md index be3452e..1490851 100644 --- a/flash-extensions/flash-ext-vite/docs/README.md +++ b/flash-extensions/flash-ext-vite/docs/README.md @@ -55,6 +55,7 @@ in agreement. | `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 | | |---|---|---| @@ -103,11 +104,18 @@ prefers specific routes over the wildcard. | `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 `Accept` names `text/html` (a browser navigating to `/content/2`) | `index.html`, so the client-side router takes it | +| 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`. diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java index 5f34a98..245e232 100644 --- a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java @@ -5,6 +5,7 @@ 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; @@ -59,13 +60,22 @@ final class Assets { Map.entry("otf", "font/otf"), Map.entry("wasm", "application/wasm")); - record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {} + 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 byPath; private final Asset index; + private final boolean navigationOnly; - /** Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under {@code basePath}. */ - Assets(ClassLoader loader, String basePath) { + /** + * 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): " @@ -107,22 +117,20 @@ final class Assets { } /** - * The asset at the request's path. A path that is none is a client-side route when a browser - * navigates to it ({@code Accept} names HTML), so it gets {@code index.html}; for anything - * else, an API call or a missing script, it is the app's own 404. + * 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) { - String accept = req.header("Accept"); - if (accept == null || !accept.contains("text/html")) throw HttpException.notFound(req.path()); + if (navigationOnly && !navigation(req)) throw HttpException.notFound(req.path()); asset = index; } res.header(asset.cache); if (asset.etag != null) { res.header(asset.etag); - String known = req.header("If-None-Match"); - if (known != null && known.contains(asset.tag)) { + if (indexOf(req.headerView("If-None-Match"), asset.tag, 0) >= 0) { res.status(HttpStatus.NOT_MODIFIED); return; } @@ -130,7 +138,7 @@ final class Assets { res.type(asset.type); if (asset.gzip != null) { res.header(VARY); - if (acceptsGzip(req.header("Accept-Encoding"))) { + if (acceptsGzip(req.headerView("Accept-Encoding"))) { res.header(GZIP).body(asset.gzip); return; } @@ -138,33 +146,58 @@ final class Assets { 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(String accept) { - if (accept == null) return false; - int at = accept.indexOf("gzip"); + static boolean acceptsGzip(ByteView accept) { + int at = indexOf(accept, GZIP_TOKEN, 0); if (at < 0) return false; - int end = accept.indexOf(',', at); - if (end < 0) end = accept.length(); - int q = accept.indexOf("q=", at); + 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++) { - char c = accept.charAt(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 = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII); + 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), tag); + 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) { diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java index 65f4aa1..897663d 100644 --- a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java @@ -20,6 +20,7 @@ public final class ViteExtension implements FlashExtension { 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) { @@ -40,13 +41,23 @@ public final class ViteExtension implements FlashExtension { 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); + Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath, navigationOnly); String everything = "/".equals(basePath) ? "/**" : basePath + "/**"; ctx.onReady(() -> { app.get(everything, (req, res) -> { diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java index 980d34e..4e0552e 100644 --- a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java @@ -1,5 +1,6 @@ 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; @@ -33,24 +34,41 @@ class AssetsTest { } } try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { - assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/").byPath.keySet()); + 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, "/")).getMessage().contains("flash-ext-vite-maven-plugin")); + assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/", true)).getMessage().contains("flash-ext-vite-maven-plugin")); } } @Test void gzipIsAcceptedUnlessRefused() { - assertTrue(Assets.acceptsGzip("gzip, deflate, br")); - assertTrue(Assets.acceptsGzip("br;q=1.0, gzip;q=0.5")); - assertFalse(Assets.acceptsGzip("gzip;q=0, br")); - assertFalse(Assets.acceptsGzip("gzip;q=0.000")); - assertFalse(Assets.acceptsGzip("br")); + 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]; } + }; + } } diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java index fe9cd16..de3ab29 100644 --- a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java @@ -16,6 +16,9 @@ class ViteExtensionTest { @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/"))); @@ -41,6 +44,7 @@ class ViteExtensionTest { 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); @@ -48,6 +52,13 @@ class ViteExtensionTest { 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"); diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index d7597dd..4b45d25 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -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}). -- 2.54.0